Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/software/thunderscope/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,9 @@ class RuntimeManagerConstants:
EXTERNAL_RUNTIMES_PATH = "/opt/tbotspython/external_runtimes"
RUNTIME_CONFIG_PATH = f"{EXTERNAL_RUNTIMES_PATH}/runtime_config.toml"

RUNTIME_EVENTS_DIRECTORY_PATH = "/tmp/tbots/stats"
RUNTIME_EVENTS_FILE = "game_events.csv"

RUNTIME_STATS_DIRECTORY_PATH = "/tmp/tbots/stats"
RUNTIME_FRIENDLY_STATS_FILE = "blue.toml"
RUNTIME_ENEMY_FROM_FRIENDLY_STATS_FILE = "yellow_from_blue.toml"
Expand Down
14 changes: 14 additions & 0 deletions src/software/thunderscope/log/stats/BUILD
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
load("@thunderscope_deps//:requirements.bzl", "requirement")

package(default_visibility = ["//visibility:public"])

py_library(
Expand All @@ -16,3 +18,15 @@ py_library(
"//software/thunderscope:thread_safe_buffer",
],
)

py_binary(
name = "analysis",
srcs = ["analysis.py"],
data = [
"//software:py_constants.so",
],
deps = [
"//software/thunderscope",
requirement("pandas"),
],
)
202 changes: 202 additions & 0 deletions src/software/thunderscope/log/stats/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import numpy as np
import pandas as pd
import pyqtgraph as pg
from pyqtgraph.exporters import ImageExporter

from software.py_constants import DIV_B_NUM_ROBOTS
from software.thunderscope.constants import RuntimeManagerConstants

CSV_PATH = f"{RuntimeManagerConstants.RUNTIME_EVENTS_DIRECTORY_PATH}/{RuntimeManagerConstants.RUNTIME_EVENTS_FILE}"
OUTPUT_DIR = RuntimeManagerConstants.RUNTIME_EVENTS_DIRECTORY_PATH

COLUMNS = {
"ball": ["ball_x", "ball_y", "ball_vx", "ball_vy"],
"robot_attrs": ["x", "y", "orientation", "vx", "vy", "angular_velocity"],
"event_type": ["event_type"],
"timestamp": ["timestamp"],
}

NUM_ROBOTS_PER_TEAM = DIV_B_NUM_ROBOTS


def load_raw_data(path: str) -> pd.DataFrame:
"""Load raw CSV data and assign column names.

:param path: Path to the CSV file.
:return: DataFrame with named columns for event_type, timestamp, ball state,
and robot state (6 robots per team, zero-indexed).
"""
cols = (
COLUMNS["event_type"]
+ COLUMNS["timestamp"]
+ COLUMNS["ball"]
+ [f"friendly_{i // 6}_{COLUMNS['robot_attrs'][i % 6]}" for i in range(36)]
+ [f"enemy_{i // 6}_{COLUMNS['robot_attrs'][i % 6]}" for i in range(36)]
)
df = pd.read_csv(path, header=None)
df.columns = cols
return df


def extract_events(df: pd.DataFrame) -> pd.DataFrame:
"""Extract event data with ball state.

:param df: Raw DataFrame from load_raw_data().
:return: DataFrame with columns: event_type, timestamp, ball_x, ball_y,
ball_vx, ball_vy.
"""
return df[COLUMNS["event_type"] + COLUMNS["timestamp"] + COLUMNS["ball"]].copy()


def extract_robots(df: pd.DataFrame) -> pd.DataFrame:
"""Extract robot state data into long format.

:param df: Raw DataFrame from load_raw_data().
:return: DataFrame with columns: timestamp, robot_index (0-5), team, x, y,
orientation, vx, vy, angular_velocity. Each row represents one robot
at one timestamp.
"""
records = []
for team in ["friendly", "enemy"]:
for robot_idx in range(NUM_ROBOTS_PER_TEAM):
cols = [f"{team}_{robot_idx}_{attr}" for attr in COLUMNS["robot_attrs"]]
records.append(
pd.DataFrame(
{
"timestamp": df["timestamp"],
"robot_index": robot_idx,
"team": team,
**{
attr: df[col]
for attr, col in zip(COLUMNS["robot_attrs"], cols)
},
}
)
)
return pd.concat(records, ignore_index=True)


def plot_shots(events_df: pd.DataFrame, output_path: str) -> None:
"""Plot cumulative shots on goal over time.

:param events_df: DataFrame from extract_events().
:param output_path: Path to save the PNG plot.
"""
shots = events_df[events_df["event_type"] == "shot_on_goal"].copy()
shots = shots.sort_values("timestamp")
shots["time_relative_ms"] = shots["timestamp"] - shots["timestamp"].min()
shots["cumulative"] = range(1, len(shots) + 1)

pg.setConfigOption("antialias", True)
plot = pg.plot()

plot.plot(
shots["time_relative_ms"].values / 1000,
shots["cumulative"].values,
pen={"color": "g", "width": 2},
symbol="o",
symbolSize=8,
symbolBrush=pg.mkBrush("g"),
)

plot.setLabel("bottom", "Time (seconds)")
plot.setLabel("left", "Total Shots")
plot.setTitle("Cumulative Shots on Goal")

ImageExporter(plot.plotItem).export(output_path)
print(f"Saved plot to {output_path}")


def plot_robot_heatmap(robots_df: pd.DataFrame, output_path: str) -> None:
"""Plot a heatmap of robot positions on the field.

SSL Division B field dimensions:
- Field: 9.0m x 6.0m
"""
x = robots_df["x"].to_numpy()
y = robots_df["y"].to_numpy()

field_x_min, field_x_max = -4.5, 4.5
field_y_min, field_y_max = -3.0, 3.0

bins = 80

heatmap, xedges, yedges = np.histogram2d(
x,
y,
bins=bins,
range=[[field_x_min, field_x_max], [field_y_min, field_y_max]],
)

pg.setConfigOption("antialias", True)

plot_widget = pg.PlotWidget()
plot_item = plot_widget.getPlotItem()

img = pg.ImageItem(heatmap.T)

img.setRect(
field_x_min,
field_y_min,
field_x_max - field_x_min,
field_y_max - field_y_min,
)

# Apply colormap
cmap = pg.colormap.get("viridis")
img.setColorMap(cmap)

plot_item.addItem(img)

plot_item.setLabel("bottom", "X (meters)")
plot_item.setLabel("left", "Y (meters)")
plot_item.setTitle("Robot Position Heatmap")

plot_item.setXRange(field_x_min, field_x_max)
plot_item.setYRange(field_y_min, field_y_max)

plot_item.showGrid(x=True, y=True)

# ---- sanity check point ----
sanity_x = 1
sanity_y = 0

plot_item.plot(
[sanity_x],
[sanity_y],
pen=None,
symbol="o",
symbolSize=10,
symbolBrush="red",
)

# label it so you know what you're looking at
text = pg.TextItem("(1,0)", anchor=(0, 1))
text.setPos(sanity_x, sanity_y)
plot_item.addItem(text)
# ----------------------------

exporter = ImageExporter(plot_item)
exporter.export(output_path)

print(f"Saved heatmap to {output_path}")


def main() -> None:
"""Run exploratory analysis on game events data."""
df = load_raw_data(CSV_PATH)
events_df = extract_events(df)
robots_df = extract_robots(df)

print()
print("Events summary:")
print()
print(events_df["event_type"].value_counts().to_string())
print()

plot_shots(events_df, f"{OUTPUT_DIR}/shots_over_time.png")
plot_robot_heatmap(robots_df, f"{OUTPUT_DIR}/robot_heatmap.png")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions src/software/thunderscope/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ colorama==0.4.6
netifaces==0.11.0
evdev==1.7.0; sys_platform == "linux"
numpy==1.26.4
pandas==3.0.2
protobuf==6.31.1
pyqtgraph==0.13.7
pyqtdarktheme-fork==2.3.2
Expand Down
67 changes: 66 additions & 1 deletion src/software/thunderscope/requirements_lock.darwin.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,62 @@ numpy==1.26.4 \
--hash=sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f
# via
# -r software/thunderscope/requirements.in
# pandas
# pyqtgraph
packaging==24.2 \
--hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \
--hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f
# via qtpy
pandas==3.0.2 \
--hash=sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c \
--hash=sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288 \
--hash=sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991 \
--hash=sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd \
--hash=sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db \
--hash=sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d \
--hash=sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18 \
--hash=sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d \
--hash=sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb \
--hash=sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660 \
--hash=sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd \
--hash=sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482 \
--hash=sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276 \
--hash=sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677 \
--hash=sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e \
--hash=sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84 \
--hash=sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa \
--hash=sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76 \
--hash=sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb \
--hash=sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53 \
--hash=sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9 \
--hash=sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702 \
--hash=sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0 \
--hash=sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1 \
--hash=sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14 \
--hash=sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39 \
--hash=sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4 \
--hash=sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0 \
--hash=sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8 \
--hash=sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d \
--hash=sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf \
--hash=sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d \
--hash=sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172 \
--hash=sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3 \
--hash=sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d \
--hash=sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d \
--hash=sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e \
--hash=sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7 \
--hash=sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668 \
--hash=sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b \
--hash=sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c \
--hash=sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235 \
--hash=sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535 \
--hash=sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df \
--hash=sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f \
--hash=sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f \
--hash=sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043 \
--hash=sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab
# via -r software/thunderscope/requirements.in
protobuf==6.31.1 \
--hash=sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16 \
--hash=sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447 \
Expand Down Expand Up @@ -120,7 +171,21 @@ pyqtgraph==0.13.7 \
--hash=sha256:64f84f1935c6996d0e09b1ee66fe478a7771e3ca6f3aaa05f00f6e068321d9e3 \
--hash=sha256:7754edbefb6c367fa0dfb176e2d0610da3ada20aa7a5318516c74af5fb72bf7a
# via -r software/thunderscope/requirements.in
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via pandas
qtawesome==1.4.0 \
--hash=sha256:783e414d1317f3e978bf67ea8e8a1b1498bad9dbd305dec814027e3b50521be6 \
--hash=sha256:a4d689fa071c595aa6184171ce1f0f847677cb8d2db45382c43129f1d72a3d93
# via -r software/thunderscope/requirements.in
qtpy==2.4.2 \
--hash=sha256:5a696b1dd7a354cb330657da1d17c20c2190c72d4888ba923f8461da67aa1a1c \
--hash=sha256:9d6ec91a587cc1495eaebd23130f7619afa5cdd34a277acb87735b4ad7c65156
# via pyqt-toast-notification
# via
# pyqt-toast-notification
# qtawesome
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
Loading
Loading