Skip to content

Commit 39257ee

Browse files
committed
replay: add stuck_detector.py, an FFT-based offline stuck-match finder
Offline analysis tool (not a live custom_referee rule) that scans a replay for windows where the ball is near-frozen and at least one robot's position trace is genuinely oscillating rather than converging or settling — distinguished via the strongest non-DC FFT bin's share of total non-DC energy (a real oscillation concentrates there; a settling/decaying approach spreads energy thinly across many bins). Already proven useful: running it over the gap #6 tournament replay corpus surfaced two real, multi-hundred-second stuck-match bugs, both fixed in prior commits (PressAndContainTactic's no-possession target computation, GiveAndGoTactic's unbounded pass handshake). Two known precision limitations remain unaddressed deliberately for now (kickoff-standstill false positives; merged-window span not fully re-verified as frozen throughout) — see docs/testing_gaps.md gap #11.
1 parent d6b3ff1 commit 39257ee

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Offline detector for "stuck" windows in a replay: the ball frozen while a
2+
robot oscillates instead of making progress.
3+
4+
This is a prototype analysis tool, not a live `custom_referee` rule (see
5+
`docs/testing_gaps.md` gap #11) — it runs after a match, over a recorded
6+
`.pkl` replay, and reports candidate windows for a human/agent to look at
7+
with `render_window()`. It intentionally does not (and should not, yet) feed
8+
back into any in-match decision: a false "stuck" call inside a live rule
9+
would be exactly the class of referee bug tracked as gap #9 in the same
10+
doc, so this stays an offline diagnostic until validated against real
11+
replays.
12+
13+
Two independent per-window signals, both required to flag a window:
14+
15+
- **Ball frozen**: the ball's position standard deviation over the window
16+
is below `ball_still_tol` (metres). A ball that's genuinely in play
17+
(being dribbled, passed, rolling to a stop) moves more than this within
18+
any multi-second window; one that's stuck (wedged against a robot,
19+
sitting in a corner while nothing resolves it) doesn't.
20+
- **A robot oscillating, not converging**: for each robot's x and y
21+
position trace over the window, take the FFT, and look at the energy in
22+
frequencies above `min_oscillation_hz` relative to total energy (excluding
23+
DC). A robot settling into a hold position has its energy concentrated
24+
near DC; a robot flip-flopping (e.g. two robots endlessly contesting one
25+
point, or a path planner alternating detour sides — see the goalkeeper
26+
bug this same investigation found, `docs/roadmap.md`'s "Goalkeeper
27+
overshoot" entry) shows up as a real spectral peak away from DC.
28+
29+
Both conditions together, sustained for `min_duration_s`, are what
30+
distinguish a genuine stuck match from an ordinary contested-ball moment
31+
(ball frozen alone also happens at every legal stoppage; oscillation alone
32+
also happens during normal marking/jostling) — see gap #11 in
33+
`docs/testing_gaps.md` for the reasoning and the "run this over the gap #6
34+
replays first" validation plan before ever wiring this into live play.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
from dataclasses import dataclass
40+
from pathlib import Path
41+
from typing import Union
42+
43+
import numpy as np
44+
45+
from utama_core.entities.game import GameFrame
46+
from utama_core.replay.replay_player import _load_replay
47+
48+
49+
@dataclass(frozen=True)
50+
class StuckWindow:
51+
"""One candidate stuck window found in a replay."""
52+
53+
t_start: float
54+
t_end: float
55+
ball_std: float
56+
"""Ball position std-dev (metres) over the window — low means frozen."""
57+
oscillating_robot_ids: tuple[int, ...]
58+
"""Friendly-team robot ids whose x/y trace showed non-DC spectral energy."""
59+
60+
61+
def _dominant_non_dc_fraction(trace: np.ndarray) -> float:
62+
"""Fraction of a 1D signal's spectral energy concentrated in its single
63+
strongest non-DC frequency bin.
64+
65+
Near 0 for a settled hold or a monotonic transient (a decaying
66+
approach, a one-way drift) — both spread their (small) non-DC energy
67+
thinly across many bins, since neither is periodic. Near 1 for a
68+
genuinely oscillating signal (energy concentrated at one repeating
69+
frequency) — e.g. two robots flip-flopping around a contested point,
70+
or a path planner alternating detour sides. `trace` is assumed evenly
71+
sampled (true for a fixed-tick-rate replay). Looking for a *peak*
72+
rather than "any non-DC energy" is what tells a real oscillation apart
73+
from ordinary spectral leakage off a sharp but non-repeating motion —
74+
a flat "sum of non-DC energy" measure flags both alike.
75+
"""
76+
n = len(trace)
77+
if n < 4:
78+
return 0.0
79+
centered = trace - trace.mean()
80+
spectrum = np.abs(np.fft.rfft(centered)) ** 2
81+
non_dc = spectrum[1:]
82+
total = non_dc.sum()
83+
if total <= 1e-12:
84+
return 0.0
85+
return float(non_dc.max() / total)
86+
87+
88+
def find_stuck_windows(
89+
replay_path: Union[str, Path],
90+
*,
91+
window_s: float = 3.0,
92+
stride_s: float = 1.0,
93+
ball_still_tol: float = 0.05,
94+
oscillation_energy_tol: float = 0.8,
95+
min_duration_s: float = 3.0,
96+
) -> list[StuckWindow]:
97+
"""Slide a `window_s`-wide window (every `stride_s`) across a replay and
98+
flag windows where the ball is frozen (`ball_std < ball_still_tol`) and
99+
at least one friendly robot's position trace has non-DC spectral energy
100+
fraction above `oscillation_energy_tol`.
101+
102+
Adjacent/overlapping flagged windows are merged before returning, and
103+
merged spans shorter than `min_duration_s` are dropped — a single
104+
flagged 3s window on its own is exactly `window_s`, so `min_duration_s`
105+
only starts filtering once windows are tuned to overlap more (smaller
106+
`stride_s`) or `window_s` itself is shortened.
107+
"""
108+
frames: list[GameFrame] = [obj for obj in _load_replay(replay_path) if isinstance(obj, GameFrame)]
109+
if not frames:
110+
return []
111+
112+
t0 = frames[0].ts
113+
t_last = frames[-1].ts
114+
115+
raw_windows: list[StuckWindow] = []
116+
t = t0
117+
while t + window_s <= t_last:
118+
window_frames = [f for f in frames if t <= f.ts <= t + window_s]
119+
if len(window_frames) >= 4 and all(f.ball is not None for f in window_frames):
120+
ball_xs = np.array([f.ball.p.x for f in window_frames])
121+
ball_ys = np.array([f.ball.p.y for f in window_frames])
122+
ball_std = float(np.hypot(ball_xs.std(), ball_ys.std()))
123+
124+
if ball_std < ball_still_tol:
125+
oscillating: list[int] = []
126+
robot_ids = set.intersection(*(set(f.friendly_robots) for f in window_frames))
127+
for rid in sorted(robot_ids):
128+
xs = np.array([f.friendly_robots[rid].p.x for f in window_frames])
129+
ys = np.array([f.friendly_robots[rid].p.y for f in window_frames])
130+
energy = max(_dominant_non_dc_fraction(xs), _dominant_non_dc_fraction(ys))
131+
if energy > oscillation_energy_tol:
132+
oscillating.append(rid)
133+
134+
if oscillating:
135+
raw_windows.append(
136+
StuckWindow(
137+
t_start=t,
138+
t_end=t + window_s,
139+
ball_std=ball_std,
140+
oscillating_robot_ids=tuple(oscillating),
141+
)
142+
)
143+
t += stride_s
144+
145+
return _merge_windows(raw_windows, min_duration_s=min_duration_s)
146+
147+
148+
def _merge_windows(windows: list[StuckWindow], *, min_duration_s: float) -> list[StuckWindow]:
149+
if not windows:
150+
return []
151+
152+
merged: list[StuckWindow] = []
153+
current = windows[0]
154+
for w in windows[1:]:
155+
if w.t_start <= current.t_end:
156+
current = StuckWindow(
157+
t_start=current.t_start,
158+
t_end=max(current.t_end, w.t_end),
159+
ball_std=max(current.ball_std, w.ball_std),
160+
oscillating_robot_ids=tuple(sorted(set(current.oscillating_robot_ids) | set(w.oscillating_robot_ids))),
161+
)
162+
else:
163+
merged.append(current)
164+
current = w
165+
merged.append(current)
166+
167+
return [w for w in merged if (w.t_end - w.t_start) >= min_duration_s]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for `utama_core.replay.stuck_detector` (gap #11 prototype)."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
import pickle
7+
8+
import pytest
9+
10+
from utama_core.entities.data.vector import Vector2D, Vector3D
11+
from utama_core.entities.game import Ball, GameFrame, Robot
12+
from utama_core.replay.entities import ReplayMetadata
13+
from utama_core.replay.stuck_detector import find_stuck_windows
14+
15+
_TICK_HZ = 60
16+
17+
18+
def _robot(rid: int, x: float, y: float) -> Robot:
19+
return Robot(
20+
id=rid, is_friendly=True, has_ball=False, p=Vector2D(x, y), v=Vector2D(0, 0), a=Vector2D(0, 0), orientation=0.0
21+
)
22+
23+
24+
def _write_replay(path, frame_fn, n_ticks: int, dt: float = 1.0 / _TICK_HZ):
25+
with open(path, "wb") as f:
26+
pickle.dump(ReplayMetadata(my_team_is_yellow=True, exp_friendly=1, exp_enemy=0), f)
27+
for i in range(n_ticks):
28+
pickle.dump(frame_fn(i * dt, i), f)
29+
30+
31+
def test_flags_frozen_ball_with_oscillating_robot(tmp_path):
32+
"""Ball parked; robot 0 oscillates at 1Hz with 0.4m amplitude — a stuck point."""
33+
34+
def frame(ts: float, i: int) -> GameFrame:
35+
osc_x = 0.4 * math.sin(2 * math.pi * 1.0 * ts)
36+
return GameFrame(
37+
ts=ts,
38+
my_team_is_yellow=True,
39+
my_team_is_right=True,
40+
friendly_robots={0: _robot(0, osc_x, 0.0)},
41+
enemy_robots={},
42+
ball=Ball(p=Vector3D(1.0, 1.0, 0.0), v=Vector3D(0, 0, 0), a=Vector3D(0, 0, 0)),
43+
)
44+
45+
path = tmp_path / "stuck.pkl"
46+
_write_replay(path, frame, n_ticks=6 * _TICK_HZ) # 6s
47+
48+
windows = find_stuck_windows(path, window_s=3.0, stride_s=1.0, min_duration_s=3.0)
49+
50+
assert windows, "expected at least one stuck window to be flagged"
51+
assert all(0 in w.oscillating_robot_ids for w in windows)
52+
assert all(w.ball_std < 0.05 for w in windows)
53+
54+
55+
def test_does_not_flag_normal_play(tmp_path):
56+
"""Ball moving steadily, robot chasing it — ordinary play, not stuck."""
57+
58+
def frame(ts: float, i: int) -> GameFrame:
59+
return GameFrame(
60+
ts=ts,
61+
my_team_is_yellow=True,
62+
my_team_is_right=True,
63+
friendly_robots={0: _robot(0, ts * 0.5, 0.0)},
64+
enemy_robots={},
65+
ball=Ball(p=Vector3D(ts * 0.6, 0.0, 0.0), v=Vector3D(0.6, 0, 0), a=Vector3D(0, 0, 0)),
66+
)
67+
68+
path = tmp_path / "normal.pkl"
69+
_write_replay(path, frame, n_ticks=6 * _TICK_HZ)
70+
71+
windows = find_stuck_windows(path, window_s=3.0, stride_s=1.0, min_duration_s=3.0)
72+
assert windows == []
73+
74+
75+
def test_does_not_flag_robot_settling_to_a_stop(tmp_path):
76+
"""Ball frozen (legal stoppage), robot converging to a hold point — not oscillation."""
77+
78+
def frame(ts: float, i: int) -> GameFrame:
79+
settled_x = 1.0 * math.exp(-ts) # decays toward 0, no oscillation
80+
return GameFrame(
81+
ts=ts,
82+
my_team_is_yellow=True,
83+
my_team_is_right=True,
84+
friendly_robots={0: _robot(0, settled_x, 0.0)},
85+
enemy_robots={},
86+
ball=Ball(p=Vector3D(2.0, 2.0, 0.0), v=Vector3D(0, 0, 0), a=Vector3D(0, 0, 0)),
87+
)
88+
89+
path = tmp_path / "settling.pkl"
90+
_write_replay(path, frame, n_ticks=6 * _TICK_HZ)
91+
92+
windows = find_stuck_windows(path, window_s=3.0, stride_s=1.0, min_duration_s=3.0)
93+
assert windows == []
94+
95+
96+
def test_empty_replay_returns_no_windows(tmp_path):
97+
path = tmp_path / "empty.pkl"
98+
with open(path, "wb") as f:
99+
pickle.dump(ReplayMetadata(my_team_is_yellow=True, exp_friendly=1, exp_enemy=0), f)
100+
101+
assert find_stuck_windows(path) == []

0 commit comments

Comments
 (0)