Add headless simulation benchmarking and tests for determinism
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 2m53s
All checks were successful
Build Simulation and Test / Run All Tests (push) Successful in 2m53s
This commit is contained in:
parent
f56192ab8f
commit
8f17498b88
@ -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:
|
||||||
|
|||||||
@ -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
93
tests/benchmarking.py
Normal 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
57
tests/test_determinism.py
Normal 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"
|
||||||
@ -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
21
uv.lock
generated
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user