Skip to content

Commit 268ab5b

Browse files
committed
test(map): add benchmarks for v1 and q10 map parsing and rendering
1 parent 840139a commit 268ab5b

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

‎tests/map/test_benchmarks.py‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Benchmarks for Roborock V1 and Q10 map parsing and rendering.
2+
3+
Can be run via pytest:
4+
uv run pytest tests/map/test_benchmarks.py
5+
uv run pytest tests/map/test_benchmarks.py --codspeed
6+
7+
Or directly as a standalone profiling CLI:
8+
uv run python -m tests.map.test_benchmarks
9+
uv run python -m tests.map.test_benchmarks --iterations 20 --profile
10+
"""
11+
12+
import argparse
13+
import cProfile
14+
import io
15+
import pstats
16+
import time
17+
from collections.abc import Callable
18+
from pathlib import Path
19+
from typing import TYPE_CHECKING, Any
20+
21+
import pytest
22+
23+
from roborock.map.b01_q10_map_parser import (
24+
B01Q10MapParser,
25+
Q10MapPacket,
26+
Q10MapPacketKind,
27+
Q10Room,
28+
parse_map_packet,
29+
)
30+
from roborock.map.map_parser import MapParser, MapParserConfig
31+
32+
if TYPE_CHECKING:
33+
from pytest_codspeed import BenchmarkFixture
34+
else:
35+
try:
36+
from pytest_codspeed import BenchmarkFixture
37+
except ImportError:
38+
BenchmarkFixture = Any
39+
40+
@pytest.fixture
41+
def benchmark() -> Callable[..., Any]:
42+
def _runner(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
43+
return func(*args, **kwargs)
44+
45+
return _runner
46+
47+
48+
_TESTDATA_DIR = Path(__file__).resolve().parent / "testdata"
49+
_V1_S5_MAP = _TESTDATA_DIR / "v1" / "s5_fw2008_with_segments.bin"
50+
_V1_S6_MAP = _TESTDATA_DIR / "v1" / "s6_fw2652_with_active_segment_and_no_mop_zone.bin"
51+
_Q10_MAP = _TESTDATA_DIR / "b01_q10_map.bin"
52+
53+
54+
def _build_synthetic_q10_grid(width: int = 200, height: int = 200) -> Q10MapPacket:
55+
"""Generate a realistic 200x200 4-room Q10 occupancy grid for load benchmarking."""
56+
grid = bytearray([243] * (width * height))
57+
for y in range(20, height - 20):
58+
for x in range(20, width - 20):
59+
if x == 20 or x == width - 21 or y == 20 or y == height - 21 or x == width // 2 or y == height // 2:
60+
grid[y * width + x] = 245
61+
else:
62+
rx = 0 if x < width // 2 else 1
63+
ry = 0 if y < height // 2 else 1
64+
room_id = 1 + ry * 2 + rx
65+
grid[y * width + x] = room_id * 4
66+
67+
rooms = [
68+
Q10Room(
69+
id=room_id,
70+
raw_name=f"Room {room_id}",
71+
pixel_value=room_id * 4,
72+
pixel_count=grid.count(room_id * 4),
73+
)
74+
for room_id in range(1, 5)
75+
]
76+
return Q10MapPacket(
77+
kind=Q10MapPacketKind.CURRENT,
78+
map_id=1,
79+
width=width,
80+
height=height,
81+
grid=bytes(grid),
82+
rooms=rooms,
83+
)
84+
85+
86+
def test_benchmark_v1_s5_map_parse(benchmark: BenchmarkFixture) -> None:
87+
"""Benchmark parsing a real Roborock V1 S5 map with 2 rooms."""
88+
raw_data = _V1_S5_MAP.read_bytes()
89+
parser = MapParser(MapParserConfig())
90+
result = benchmark(parser.parse, raw_data)
91+
assert result is not None
92+
assert result.image_content is not None
93+
94+
95+
def test_benchmark_v1_s6_map_parse(benchmark: BenchmarkFixture) -> None:
96+
"""Benchmark parsing a real Roborock V1 S6 map with 6 rooms and active segment."""
97+
raw_data = _V1_S6_MAP.read_bytes()
98+
parser = MapParser(MapParserConfig())
99+
result = benchmark(parser.parse, raw_data)
100+
assert result is not None
101+
assert result.image_content is not None
102+
103+
104+
def test_benchmark_q10_map_packet_unpack(benchmark: BenchmarkFixture) -> None:
105+
"""Benchmark Q10 wire packet decompression and metadata decoding."""
106+
raw_data = _Q10_MAP.read_bytes()
107+
packet = benchmark(parse_map_packet, raw_data)
108+
assert packet.width > 0
109+
assert packet.height > 0
110+
111+
112+
def test_benchmark_q10_map_parse_and_render(benchmark: BenchmarkFixture) -> None:
113+
"""Benchmark end-to-end Q10 small map packet decoding and PNG rendering."""
114+
raw_data = _Q10_MAP.read_bytes()
115+
parser = B01Q10MapParser()
116+
result = benchmark(parser.parse, raw_data)
117+
assert result.image_content is not None
118+
119+
120+
def test_benchmark_q10_map_full_scale_render(benchmark: BenchmarkFixture) -> None:
121+
"""Benchmark Q10 composite rendering on a realistic 200x200 4-room floorplan."""
122+
packet = _build_synthetic_q10_grid(200, 200)
123+
parser = B01Q10MapParser()
124+
result = benchmark(parser.parse_packet, packet)
125+
assert result.image_content is not None
126+
127+
128+
# ---------------------------------------------------------------------------
129+
# Standalone CLI Benchmark & Profiler
130+
# ---------------------------------------------------------------------------
131+
132+
133+
def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None:
134+
v1_s5_data = _V1_S5_MAP.read_bytes()
135+
v1_s6_data = _V1_S6_MAP.read_bytes()
136+
q10_data = _Q10_MAP.read_bytes()
137+
q10_packet_200 = _build_synthetic_q10_grid(200, 200)
138+
139+
v1_parser = MapParser(MapParserConfig())
140+
q10_parser = B01Q10MapParser()
141+
142+
benchmarks: list[tuple[str, Callable[[], Any]]] = [
143+
("V1 S5 Map (Parse & Render)", lambda: v1_parser.parse(v1_s5_data)),
144+
("V1 S6 Map (Parse & Render)", lambda: v1_parser.parse(v1_s6_data)),
145+
("Q10 Wire Packet (Unpack only)", lambda: parse_map_packet(q10_data)),
146+
("Q10 Map (Parse & Render)", lambda: q10_parser.parse(q10_data)),
147+
("Q10 200x200 Map (Composite Render)", lambda: q10_parser.parse_packet(q10_packet_200)),
148+
]
149+
150+
print(f"\nRunning {len(benchmarks)} benchmarks ({warmup} warmup, {iterations} timed iterations)...\n")
151+
results = []
152+
153+
for name, fn in benchmarks:
154+
for _ in range(warmup):
155+
fn()
156+
157+
timings: list[float] = []
158+
for _ in range(iterations):
159+
start = time.perf_counter()
160+
fn()
161+
timings.append((time.perf_counter() - start) * 1000.0)
162+
163+
timings.sort()
164+
min_ms = timings[0]
165+
mean_ms = sum(timings) / len(timings)
166+
med_ms = timings[len(timings) // 2]
167+
p95_ms = timings[int(len(timings) * 0.95)]
168+
ops_per_sec = 1000.0 / mean_ms if mean_ms > 0 else float("inf")
169+
170+
results.append((name, min_ms, med_ms, mean_ms, p95_ms, ops_per_sec))
171+
172+
if profile:
173+
pr = cProfile.Profile()
174+
pr.enable()
175+
for _ in range(iterations):
176+
fn()
177+
pr.disable()
178+
s = io.StringIO()
179+
ps = pstats.Stats(pr, stream=s).sort_stats(pstats.SortKey.CUMULATIVE)
180+
ps.print_stats(10)
181+
print(f"--- cProfile Top Hotspots: {name} ---")
182+
print(s.getvalue())
183+
184+
header = (
185+
f"{'Benchmark':<36} | {'Min (ms)':>9} | {'Med (ms)':>9} | {'Mean (ms)':>9} | {'P95 (ms)':>9} | {'Ops/sec':>10}"
186+
)
187+
print(header)
188+
print("-" * len(header))
189+
for name, min_ms, med_ms, mean_ms, p95_ms, ops in results:
190+
print(f"{name:<36} | {min_ms:9.3f} | {med_ms:9.3f} | {mean_ms:9.3f} | {p95_ms:9.3f} | {ops:10.1f}")
191+
print()
192+
193+
194+
def main() -> None:
195+
parser = argparse.ArgumentParser(description="Run Roborock map parser benchmarks")
196+
parser.add_argument("--iterations", type=int, default=20, help="Number of benchmark iterations (default: 20)")
197+
parser.add_argument("--warmup", type=int, default=3, help="Number of warmup iterations (default: 3)")
198+
parser.add_argument("--profile", action="store_true", help="Print cProfile hotspot breakdown for each benchmark")
199+
args = parser.parse_args()
200+
201+
_run_benchmarks(iterations=args.iterations, warmup=args.warmup, profile=args.profile)
202+
203+
204+
if __name__ == "__main__":
205+
main()

0 commit comments

Comments
 (0)