Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ repos:
- id: black
args: ["--line-length", "120"]
files: \.py$
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.261 # replace with latest stable version
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
hooks:
- id: ruff
args: ["--fix"] # auto-fix issues like formatting, unused imports
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ exists yet: say so in your change rather than guessing.
| Referee | `utama_core/custom_referee/` | `docs/custom_referee.md` | |
| Motion planning | `utama_core/motion_planning/`, `tools/motion_planning_benchmark.py` | `docs/motion_planning_comparison.md` | |
| Simulation | `utama_core/rsoccer_simulator/`, `vendor/rSim/` | `vendor/rSim/FORK_NOTES.md` | `vendor/rSim` is the C++ physics fork; `rsoccer_simulator` is the Python env on top of it |
| Real robots (radio, vision, controllers) | `utama_core/team_controller/`, `data_processing/` | `TODO` (see `team_controller/README.md`) | |
| Real robots (radio, vision, controllers) | `utama_core/team_controller/`, `utama_core/data_processing/` | `utama_core/team_controller/README.md` (brief) | |

## Glossary

Expand Down
Binary file removed assets/images/pipeline.drawio.png
Binary file not shown.
7 changes: 0 additions & 7 deletions docs/STRATEGY_DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,10 @@ whoever runs it.
spatially. Use raw frames only for an exact number once the picture has localized the issue.
- **`render_clip()`** (`analysis/render_clip.py`) — MP4 of a window for humans, ball-following
or full-pitch camera; sim ball teleports are never shown. Needs `ffmpeg`. Commands behind
<<<<<<< HEAD
committed clips: `assets/clips/README.md`.
- **`docs/strategies.md`** — every factory, whether a round-robin plays it or it is retired,
and the latest results.
- **`tools/evaluation/round_robin.py`** — round-robin runner (`--max-workers N`, `--both-sides`,
=======
committed clips: `assets/clips/README.md`.
- **`docs/strategies.md`** — every factory's status and the latest results. `baseline`
strategies aren't meant to win; don't tune them to.
- **`tools/evaluation/round_robin.py`** — round-robin runner (`--max-workers N`, `--both-sides`,
>>>>>>> repo-cleanup
`--strict`, `--stop-at-first-stall`, `--fuzz-restarts SEED`, `--fuzz-interval LO HI`,
`--no-save`, `--pair A B` for one fixture with A as config_a — reruns a stalled match from a
round-robin; see the determinism caveat below).
Expand Down
1 change: 1 addition & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Every script in the repository, by what it is for. Run them from the repository
| `lint` | the whole pre-commit stack (black, ruff, isort) on every file |
| `precommit-install` / `precommit-uninstall` | install or remove the pre-commit hook |
| `main` | `main.py`: watch one match between two strategies in rsim (below) |
| `build-planner` | build the C++ fast-path planner (`utama_core/motion_planning/src/fastpathplanning/cpp`) into `build/fpp` with cmake |
| `replay [-n NAME] [-p]` | play a legacy pickle replay `replays/NAME.pkl` in the rSoccer viewer (newest if no name; `-p` step by step). Today's `.npz` replays open in the dashboard instead |
| `runs` | list the tournament runs in `replays/`: start time, commit, matches, stalls, arguments |
| `debug-robots` | keyboard/click teleoperation GUI for real robots over the serial radio |
Expand Down
3 changes: 0 additions & 3 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,3 @@ precommit-uninstall = "pre-commit uninstall"

[environments]
robosim = { features = ["robosim"], no-default-feature = true }

[target.linux-64.tasks]
main = "python -m main"
15 changes: 8 additions & 7 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Exclusions apply to all of ruff.
exclude = [
"*_pb2.py",
"build/",
"dist/",
]

[lint]
ignore = [
"F401", # Module imported but unused
"E402", # Module level import not at top of file
Expand All @@ -6,10 +14,3 @@ ignore = [

# Enable auto-fix for fixable errors
fixable = ["E"] # Fix all fixable E codes (like E402)

# Optionally, exclude certain files or directories
exclude = [
"*_pb2.py",
"build/",
"dist/",
]
88 changes: 2 additions & 86 deletions tools/evaluation/round_robin.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@
_stats_to_dict,
)
from tools.evaluation.match import run_match as _lib_run_match
from utama_core.analysis import chances, restart_outcomes, turnover_breakdown
from utama_core.analysis import restart_outcomes, turnover_breakdown
from utama_core.analysis.strategy_table import strategy_table
from utama_core.config.settings import REPLAY_BASE_PATH
from utama_core.replay import match_cache
from utama_core.replay.fingerprint import CodeGraph, match_key
Expand Down Expand Up @@ -738,91 +739,6 @@ def git(*args: str) -> Optional[str]:
}


def strategy_table(
results: list[dict],
real_losses_by_match: Optional[dict[str, dict[str, int]]] = None,
chances_by_match: Optional[dict[str, dict]] = None,
) -> dict[str, dict]:
"""Per-strategy totals over `summary.json`-shaped `results`. Match stats are from
config_a's side, so config_b reads the `enemy_*` counterparts. Real ball losses are only
measured for config_a (the side with an intentions log), so they are totalled over the
matches a strategy played as config_a (`matches_as_a`), in total and by kind
(`turnover_breakdown.TURNOVER_KINDS` and `RESTART_KINDS`). `stalled` counts matches that
recorded a stall event or tripped the possession backstop: their result is still in
W-D-L, flagged rather than dropped, since a stall can be the strategy's own fault.
`chances` (`analysis.chances.rates`) is measured for both sides, over the matches in
`chances_by_match`; `pass_progress_m` is the mean metres gained toward goal per
completed pass, `forward_pass_share` the share gaining at least 1 m."""
table: dict[str, dict] = {}
chance_totals: dict[str, dict[str, float]] = {}
progress: dict[str, list[float]] = {}
for r in results:
stats = r.get("stats") or {}
fouls = stats.get("fouls_by_side") or {}
tag = f"{_short_name(r['config_a'])}_vs_{_short_name(r['config_b'])}"
for name, own, is_a in ((r["config_a"], "friendly", True), (r["config_b"], "enemy", False)):
row = table.setdefault(
name,
{
"matches": 0,
"wins": 0,
"draws": 0,
"losses": 0,
"goals_for": 0,
"goals_against": 0,
"shots": 0,
"completed_passes": 0,
"attacking_third_entries": 0,
"fouls": 0,
"stalled": 0,
"matches_as_a": 0,
"real_losses_as_a": 0,
"real_loss_kinds_as_a": {},
"robot_overlap_ticks": 0,
"robot_overlap_incidents": 0,
"teammate_overlap_ticks": 0,
"teammate_overlap_incidents": 0,
"near_miss_ticks": 0,
"worst_clearance_m": None,
},
)
prefix = "" if is_a else "enemy_"
gf, ga = (r["score_a"], r["score_b"]) if is_a else (r["score_b"], r["score_a"])
row["matches"] += 1
row["wins" if gf > ga else "draws" if gf == ga else "losses"] += 1
row["goals_for"] += gf
row["goals_against"] += ga
row["shots"] += (stats.get("shots") or {}).get(own, 0)
row["completed_passes"] += stats.get(f"{prefix}completed_passes", 0)
row["attacking_third_entries"] += stats.get(f"{prefix}attacking_third_entries", 0)
row["fouls"] += sum((fouls.get(own) or {}).values())
row["stalled"] += bool(stats.get("stall_events") or r.get("possession_backstop"))
row["robot_overlap_ticks"] += stats.get("robot_overlap_ticks", 0)
row["robot_overlap_incidents"] += stats.get("robot_overlap_incidents", 0)
row["teammate_overlap_ticks"] += (stats.get("teammate_overlap_ticks") or {}).get(own, 0)
row["teammate_overlap_incidents"] += (stats.get("teammate_overlap_incidents") or {}).get(own, 0)
row["near_miss_ticks"] += stats.get("near_miss_ticks", 0)
wc = stats.get("worst_clearance_m")
if wc is not None:
if row["worst_clearance_m"] is None or wc < row["worst_clearance_m"]:
row["worst_clearance_m"] = round(wc, 3)
progress.setdefault(name, []).extend(stats.get(f"{prefix}pass_progress_m") or [])
if chances_by_match and tag in chances_by_match:
chances.add(chance_totals.setdefault(name, {}), chances.side_totals(chances_by_match[tag], own))
if is_a and real_losses_by_match is not None and tag in real_losses_by_match:
row["matches_as_a"] += 1
kinds = real_losses_by_match[tag]
row["real_losses_as_a"] += sum(kinds.values())
for kind, n in kinds.items():
row["real_loss_kinds_as_a"][kind] = row["real_loss_kinds_as_a"].get(kind, 0) + n
for name, row in table.items():
gains = progress.get(name, [])
row["pass_progress_m"] = round(sum(gains) / len(gains), 2) if gains else None
row["forward_pass_share"] = round(sum(g >= 1.0 for g in gains) / len(gains), 2) if gains else None
row["chances"] = chances.rates(chance_totals.get(name, {}))
return table


def stall_incidents(results: list[dict]) -> list[dict]:
"""Stall events over `summary.json`-shaped `results`, with deterministic duplicates
merged: the same kind, onset tick and duration tick in matches that share a strategy
Expand Down
17 changes: 2 additions & 15 deletions tools/motion_planning_benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import math
import random
import statistics
import subprocess
import sys
import time
from dataclasses import asdict, dataclass, field
Expand All @@ -45,6 +44,7 @@
from utama_core.entities.data.command import RobotCommand
from utama_core.entities.data.vector import Vector2D
from utama_core.entities.game import Game
from utama_core.global_utils.git_utils import git_revision
from utama_core.motion_planning.src.common.motion_controller import MotionController
from utama_core.run import StrategyRunner
from utama_core.skills.src.utils.move_utils import move
Expand Down Expand Up @@ -971,19 +971,6 @@ def _percentile(values: list[float], fraction: float) -> float | None:
return ordered[math.ceil(fraction * len(ordered)) - 1]


def _git_revision() -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError):
return None
return result.stdout.strip() or None


def run_case(scheme: str, scenario: Scenario, repeat: int, timing_warmup_calls: int) -> dict:
manager = BenchmarkManager(scenario)
my_strategy = _go_to_targets_strategy(scenario.friendly_targets, scenario.friendly_target_trajectories)
Expand Down Expand Up @@ -1226,7 +1213,7 @@ def main() -> int:
payload = {
"schema_version": SCHEMA_VERSION,
"generated_at_utc": generated_at.isoformat(),
"git_revision": _git_revision(),
"git_revision": git_revision(),
"mode": "rsim",
"headless": True,
"schemes": list(args.schemes),
Expand Down
15 changes: 3 additions & 12 deletions tools/scenario_bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
from tools.replay_trace import trace
from utama_core.analysis.goal_ledger import TYPES, WINDOW_S, per_match
from utama_core.config.settings import REPLAY_BASE_PATH
from utama_core.global_utils.git_utils import git_revision
from utama_core.replay import match_cache
from utama_core.replay.fingerprint import CodeGraph, bench_key
from utama_core.rsoccer_simulator.src.Simulators.robosim.robosim_wrapper import (
Expand Down Expand Up @@ -178,16 +179,6 @@
DEFAULT_WEIGHTS = BANKS / "situation_rates_real.json"


def _git_revision() -> str | None:
import subprocess

try:
result = subprocess.run(["git", "rev-parse", "--short", "HEAD"], check=True, capture_output=True, text=True)
except (OSError, subprocess.CalledProcessError):
return None
return result.stdout.strip() or None


def _parallel_map(fn: Callable, items: Iterable, workers: int) -> Iterator:
"""`map`, over `workers` processes when more than one (results stay in order)."""
if workers <= 1:
Expand All @@ -212,7 +203,7 @@ def _load_bank(args: argparse.Namespace) -> tuple[list[BenchScenario], list[Benc
elif args.harvest_from is not None:
scenarios, harvest_report = harvest_run_dir(
args.harvest_from,
evaluator_version=_git_revision() or "unknown",
evaluator_version=git_revision() or "unknown",
per_situation=args.per_situation,
workers=args.workers,
)
Expand Down Expand Up @@ -1126,7 +1117,7 @@ def main() -> int:
payload = {
"schema_version": SCHEMA_VERSION,
"generated_at_utc": generated_at.isoformat(),
"git_revision": _git_revision(),
"git_revision": git_revision(),
"candidate": args.candidate,
"baseline": args.baseline,
"against": against_source,
Expand Down
4 changes: 2 additions & 2 deletions utama_core/analysis/motion_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import json
from pathlib import Path
from typing import Dict, Optional, Union
from typing import Dict, Union

import numpy as np

Expand Down Expand Up @@ -212,7 +212,7 @@ def enrich_tournament_directory(tournament_dir: Union[str, Path]) -> None:
st["worst_clearance_m"] = m["worst_clearance_m"]

# Rebuild strategy_table if possible
from tools.evaluation.round_robin import strategy_table
from utama_core.analysis.strategy_table import strategy_table

summary["strategies"] = strategy_table(summary["results"])
with open(summary_path, "w") as f:
Expand Down
102 changes: 102 additions & 0 deletions utama_core/analysis/strategy_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Per-strategy totals over a round-robin's `summary.json`-shaped results.

Lives in `utama_core` so the analysis code can rebuild a `summary.json`'s `strategies`
table without importing the `tools/` scripts (which are not part of the installed package).
`tools/evaluation/round_robin.py` re-exports it.
"""

from __future__ import annotations

from typing import Optional

from utama_core.analysis import chances


def _short_name(config_name: str) -> str:
"""A config's name in replay file names (mirrors `tools/evaluation/match._short_name`)."""
return config_name.removeprefix("build_").removesuffix("_kernel_strategy")


def strategy_table(
results: list[dict],
real_losses_by_match: Optional[dict[str, dict[str, int]]] = None,
chances_by_match: Optional[dict[str, dict]] = None,
) -> dict[str, dict]:
"""Per-strategy totals over `summary.json`-shaped `results`. Match stats are from
config_a's side, so config_b reads the `enemy_*` counterparts. Real ball losses are only
measured for config_a (the side with an intentions log), so they are totalled over the
matches a strategy played as config_a (`matches_as_a`), in total and by kind
(`turnover_breakdown.TURNOVER_KINDS` and `RESTART_KINDS`). `stalled` counts matches that
recorded a stall event or tripped the possession backstop: their result is still in
W-D-L, flagged rather than dropped, since a stall can be the strategy's own fault.
`chances` (`analysis.chances.rates`) is measured for both sides, over the matches in
`chances_by_match`; `pass_progress_m` is the mean metres gained toward goal per
completed pass, `forward_pass_share` the share gaining at least 1 m."""
table: dict[str, dict] = {}
chance_totals: dict[str, dict[str, float]] = {}
progress: dict[str, list[float]] = {}
for r in results:
stats = r.get("stats") or {}
fouls = stats.get("fouls_by_side") or {}
tag = f"{_short_name(r['config_a'])}_vs_{_short_name(r['config_b'])}"
for name, own, is_a in ((r["config_a"], "friendly", True), (r["config_b"], "enemy", False)):
row = table.setdefault(
name,
{
"matches": 0,
"wins": 0,
"draws": 0,
"losses": 0,
"goals_for": 0,
"goals_against": 0,
"shots": 0,
"completed_passes": 0,
"attacking_third_entries": 0,
"fouls": 0,
"stalled": 0,
"matches_as_a": 0,
"real_losses_as_a": 0,
"real_loss_kinds_as_a": {},
"robot_overlap_ticks": 0,
"robot_overlap_incidents": 0,
"teammate_overlap_ticks": 0,
"teammate_overlap_incidents": 0,
"near_miss_ticks": 0,
"worst_clearance_m": None,
},
)
prefix = "" if is_a else "enemy_"
gf, ga = (r["score_a"], r["score_b"]) if is_a else (r["score_b"], r["score_a"])
row["matches"] += 1
row["wins" if gf > ga else "draws" if gf == ga else "losses"] += 1
row["goals_for"] += gf
row["goals_against"] += ga
row["shots"] += (stats.get("shots") or {}).get(own, 0)
row["completed_passes"] += stats.get(f"{prefix}completed_passes", 0)
row["attacking_third_entries"] += stats.get(f"{prefix}attacking_third_entries", 0)
row["fouls"] += sum((fouls.get(own) or {}).values())
row["stalled"] += bool(stats.get("stall_events") or r.get("possession_backstop"))
row["robot_overlap_ticks"] += stats.get("robot_overlap_ticks", 0)
row["robot_overlap_incidents"] += stats.get("robot_overlap_incidents", 0)
row["teammate_overlap_ticks"] += (stats.get("teammate_overlap_ticks") or {}).get(own, 0)
row["teammate_overlap_incidents"] += (stats.get("teammate_overlap_incidents") or {}).get(own, 0)
row["near_miss_ticks"] += stats.get("near_miss_ticks", 0)
wc = stats.get("worst_clearance_m")
if wc is not None:
if row["worst_clearance_m"] is None or wc < row["worst_clearance_m"]:
row["worst_clearance_m"] = round(wc, 3)
progress.setdefault(name, []).extend(stats.get(f"{prefix}pass_progress_m") or [])
if chances_by_match and tag in chances_by_match:
chances.add(chance_totals.setdefault(name, {}), chances.side_totals(chances_by_match[tag], own))
if is_a and real_losses_by_match is not None and tag in real_losses_by_match:
row["matches_as_a"] += 1
kinds = real_losses_by_match[tag]
row["real_losses_as_a"] += sum(kinds.values())
for kind, n in kinds.items():
row["real_loss_kinds_as_a"][kind] = row["real_loss_kinds_as_a"].get(kind, 0) + n
for name, row in table.items():
gains = progress.get(name, [])
row["pass_progress_m"] = round(sum(gains) / len(gains), 2) if gains else None
row["forward_pass_share"] = round(sum(g >= 1.0 for g in gains) / len(gains), 2) if gains else None
row["chances"] = chances.rates(chance_totals.get(name, {}))
return table
Loading
Loading