Add headless simulation benchmarking and tests for determinism
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 2m53s

This commit is contained in:
Sam 2025-06-21 18:36:02 -05:00
parent f56192ab8f
commit 8f17498b88
6 changed files with 183 additions and 13 deletions

View File

@ -104,6 +104,7 @@ class SimulationEngine:
def run(self): def run(self):
print(self.world.current_buffer)
while self.running: while self.running:
self._handle_frame() self._handle_frame()
pygame.quit() pygame.quit()
@ -200,11 +201,11 @@ class SimulationEngine:
self.hud.manager.draw_ui(self.screen) self.hud.manager.draw_ui(self.screen)
self.hud.draw_splitters(self.screen) self.hud.draw_splitters(self.screen)
self.hud.render_mouse_position(self.screen, self.camera, self.sim_view_rect) # self.hud.render_mouse_position(self.screen, self.camera, self.sim_view_rect)
self.hud.render_fps(self.screen, self.clock) # self.hud.render_fps(self.screen, self.clock)
self.hud.render_tps(self.screen, self.actual_tps) # self.hud.render_tps(self.screen, self.actual_tps)
self.hud.render_tick_count(self.screen, self.total_ticks) # self.hud.render_tick_count(self.screen, self.total_ticks)
self.hud.render_selected_objects_info(self.screen, self.input_handler.selected_objects) # self.hud.render_selected_objects_info(self.screen, self.input_handler.selected_objects)
self.hud.render_legend(self.screen, self.input_handler.show_legend) self.hud.render_legend(self.screen, self.input_handler.show_legend)
self.hud.render_pause_indicator(self.screen, self.input_handler.is_paused) self.hud.render_pause_indicator(self.screen, self.input_handler.is_paused)
if self.input_handler.selected_objects: if self.input_handler.selected_objects:

View File

@ -14,5 +14,6 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"psutil>=7.0.0",
"ruff>=0.11.12", "ruff>=0.11.12",
] ]

93
tests/benchmarking.py Normal file
View File

@ -0,0 +1,93 @@
import time
import random
import statistics
import hashlib
import pickle
class HeadlessSimulationBenchmark:
def __init__(self, setup_world, random_seed=42):
"""
:param setup_world: Callable that returns a World instance.
:param random_seed: Seed for random number generation.
"""
self.setup_world = setup_world
self.random_seed = random_seed
self.world = None
self.tps_history = []
self._running = False
self.ticks_elapsed_time = None # Track time for designated ticks
def set_random_seed(self, seed):
self.random_seed = seed
random.seed(seed)
def start(self, ticks=100, max_seconds=None):
self.set_random_seed(self.random_seed)
self.world = self.setup_world(self.random_seed)
self.tps_history.clear()
self._running = True
tick_count = 0
start_time = time.perf_counter()
last_time = start_time
# For precise tick timing
tick_timing_start = None
if ticks is not None:
tick_timing_start = time.perf_counter()
while self._running and (ticks is None or tick_count < ticks):
self.world.tick_all()
tick_count += 1
now = time.perf_counter()
elapsed = now - last_time
if elapsed > 0:
self.tps_history.append(1.0 / elapsed)
last_time = now
if max_seconds and (now - start_time) > max_seconds:
break
if ticks is not None:
tick_timing_end = time.perf_counter()
self.ticks_elapsed_time = tick_timing_end - tick_timing_start
else:
self.ticks_elapsed_time = None
self._running = False
def stop(self):
self._running = False
def get_tps_history(self):
return self.tps_history
def get_tps_average(self):
return statistics.mean(self.tps_history) if self.tps_history else 0.0
def get_tps_stddev(self):
return statistics.stdev(self.tps_history) if len(self.tps_history) > 1 else 0.0
def get_simulation_hash(self):
# Serialize the world state and hash it for determinism checks
state = []
for obj in self.world.get_objects():
state.append((
type(obj).__name__,
getattr(obj, "position", None),
getattr(obj, "rotation", None),
getattr(obj, "flags", None),
getattr(obj, "interaction_radius", None),
getattr(obj, "max_visual_width", None),
))
state_bytes = pickle.dumps(state)
return hashlib.sha256(state_bytes).hexdigest()
def get_summary(self):
return {
"tps_avg": self.get_tps_average(),
"tps_stddev": self.get_tps_stddev(),
"ticks": len(self.tps_history),
"simulation_hash": self.get_simulation_hash(),
"ticks_elapsed_time": self.ticks_elapsed_time,
}

57
tests/test_determinism.py Normal file
View File

@ -0,0 +1,57 @@
import pytest
import random
from world.world import World, Position, Rotation
from world.objects import FoodObject, DefaultCell
from tests.benchmarking import HeadlessSimulationBenchmark
# Hardcoded simulation parameters (copied from config/constants.py)
CELL_SIZE = 20
GRID_WIDTH = 30
GRID_HEIGHT = 25
FOOD_OBJECTS_COUNT = 500
RANDOM_SEED = 12345
def _setup_world(seed=RANDOM_SEED):
world = World(CELL_SIZE, (CELL_SIZE * GRID_WIDTH, CELL_SIZE * GRID_HEIGHT))
random.seed(seed)
half_width = GRID_WIDTH * CELL_SIZE // 2
half_height = GRID_HEIGHT * CELL_SIZE // 2
for _ in range(FOOD_OBJECTS_COUNT):
x = random.randint(-half_width, half_width)
y = random.randint(-half_height, half_height)
world.add_object(FoodObject(Position(x=x, y=y)))
for _ in range(300):
new_cell = DefaultCell(
Position(x=random.randint(-half_width, half_width), y=random.randint(-half_height, half_height)),
Rotation(angle=0)
)
new_cell.behavioral_model = new_cell.behavioral_model.mutate(3)
world.add_object(new_cell)
return world
def test_simulation_determinism():
bench1 = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED)
bench2 = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED)
bench1.start(ticks=100)
bench2.start(ticks=100)
hash1 = bench1.get_simulation_hash()
hash2 = bench2.get_simulation_hash()
assert hash1 == hash2, f"Simulation hashes differ: {hash1} != {hash2}"
def test_simulation_benchmark():
bench = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED+1)
tick_count = 100
bench.start(ticks=tick_count)
summary = bench.get_summary()
print(f"{tick_count} ticks took {summary.get('ticks_elapsed_time', 0):.4f} seconds, TPS avg: {summary['tps_avg']:.2f}, stddev: {summary['tps_stddev']:.2f}")
assert summary['tps_avg'] > 0, "Average TPS should be greater than zero"
assert summary['ticks_elapsed_time'] > 0, "Elapsed time should be greater than zero"

View File

@ -1,10 +1,12 @@
import pytest import pytest
from world.world import World, Position, BaseEntity from world.world import World, Position, BaseEntity, Rotation
class DummyEntity(BaseEntity): class DummyEntity(BaseEntity):
def __init__(self, position): def __init__(self, position, rotation=None):
super().__init__(position) if rotation is None:
rotation = Rotation(angle=0)
super().__init__(position, rotation)
self.ticked = False self.ticked = False
self.rendered = False self.rendered = False
@ -83,9 +85,6 @@ def test_tick_all_calls_tick(world):
def test_add_object_out_of_bounds(world): def test_add_object_out_of_bounds(world):
entity = DummyEntity(Position(x=1000, y=1000)) entity = DummyEntity(Position(x=1000, y=1000))
world.add_object(entity) world.add_object(entity)
entity = world.get_objects()[0] entity = world.get_objects()[0]
assert entity.position.x == 49 and entity.position.y == 49
assert entity.position.x == 49 and entity.position.y == 49

21
uv.lock generated
View File

@ -53,6 +53,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "psutil" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -67,7 +68,10 @@ requires-dist = [
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.11.12" }] dev = [
{ name = "psutil", specifier = ">=7.0.0" },
{ name = "ruff", specifier = ">=0.11.12" },
]
[[package]] [[package]]
name = "filelock" name = "filelock"
@ -206,6 +210,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
] ]
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.11.5" version = "2.11.5"