diff --git a/.gitignore b/.gitignore index de3ccf9c..26659796 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ docs/_build/ # GitHub App credentials gha-creds-*.json + +# pytest-benchmark storage +.benchmarks/ diff --git a/pyproject.toml b/pyproject.toml index fc031607..cc61a5de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ dev = [ "pdoc>=15.0.4,<17", "pytest-cov>=7.0.0", "zmqtt>=0.2.0,<0.3.0", + "pytest-benchmark>=5.1.0", ] [tool.hatch.build.targets.sdist] diff --git a/tests/map/test_benchmarks.py b/tests/map/test_benchmarks.py new file mode 100644 index 00000000..c1381748 --- /dev/null +++ b/tests/map/test_benchmarks.py @@ -0,0 +1,301 @@ +"""Benchmarks for Roborock V1 and Q10 map parsing and rendering. + +Includes permutations for: +- Different scaling values (scale 1, 2, 4) +- Drawable overlay variations (none, default, all) +- Real V1 S5 and S6 map payloads +- Q10 wire unpack and multi-room composite rendering +- Pixel output validation (dimensions, format, bounding box) + +Can be run via pytest: + uv run pytest tests/map/test_benchmarks.py + uv run pytest tests/map/test_benchmarks.py --benchmark-only + uv run pytest tests/map/test_benchmarks.py --benchmark-autosave + +Or directly as a standalone profiling CLI: + uv run python -m tests.map.test_benchmarks + uv run python -m tests.map.test_benchmarks --iterations 20 --profile +""" + +import argparse +import cProfile +import io +import pstats +import time +from collections.abc import Callable +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +from PIL import Image +from vacuum_map_parser_base.config.drawable import Drawable + +from roborock.map.b01_q10_map_parser import ( + B01Q10MapParser, + B01Q10MapParserConfig, + Q10MapPacket, + Q10MapPacketKind, + Q10Room, + parse_map_packet, +) +from roborock.map.map_parser import MapParser, MapParserConfig, ParsedMapData + +if TYPE_CHECKING: + from pytest_benchmark.fixture import BenchmarkFixture +else: + try: + from pytest_benchmark.fixture import BenchmarkFixture + except ImportError: + try: + from pytest_codspeed import BenchmarkFixture + except ImportError: + BenchmarkFixture = Any + + @pytest.fixture + def benchmark() -> Callable[..., Any]: + def _runner(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + return func(*args, **kwargs) + + return _runner + + +_TESTDATA_DIR = Path(__file__).resolve().parent / "testdata" +_V1_S5_MAP = _TESTDATA_DIR / "v1" / "s5_fw2008_with_segments.bin" +_V1_S6_MAP = _TESTDATA_DIR / "v1" / "s6_fw2652_with_active_segment_and_no_mop_zone.bin" +_Q10_MAP = _TESTDATA_DIR / "b01_q10_map.bin" + + +def _build_synthetic_q10_grid(width: int = 200, height: int = 200) -> Q10MapPacket: + """Generate a realistic 200x200 4-room Q10 occupancy grid for load benchmarking.""" + grid = bytearray([243] * (width * height)) + for y in range(20, height - 20): + for x in range(20, width - 20): + if x == 20 or x == width - 21 or y == 20 or y == height - 21 or x == width // 2 or y == height // 2: + grid[y * width + x] = 245 + else: + rx = 0 if x < width // 2 else 1 + ry = 0 if y < height // 2 else 1 + room_id = 1 + ry * 2 + rx + grid[y * width + x] = room_id * 4 + + rooms = [ + Q10Room( + id=room_id, + raw_name=f"Room {room_id}", + pixel_value=room_id * 4, + pixel_count=grid.count(room_id * 4), + ) + for room_id in range(1, 5) + ] + return Q10MapPacket( + kind=Q10MapPacketKind.CURRENT, + map_id=1, + width=width, + height=height, + grid=bytes(grid), + rooms=rooms, + ) + + +def _validate_image( + result: ParsedMapData | None, + expected_size: tuple[int, int] | None = None, +) -> None: + """Validate rendered PNG image integrity and pixel bounds outside the timed benchmark.""" + assert result is not None + assert result.image_content is not None + assert len(result.image_content) > 0 + image = Image.open(io.BytesIO(result.image_content)) + assert image.format == "PNG" + assert image.mode == "RGBA" + assert image.getbbox() is not None # Image has non-empty drawn content + if expected_size is not None: + assert image.size == expected_size + + +# --------------------------------------------------------------------------- +# V1 Map Benchmarks +# --------------------------------------------------------------------------- + + +def test_benchmark_v1_s5_map_parse(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S5 map with 2 rooms (default scale 4).""" + raw_data = _V1_S5_MAP.read_bytes() + parser = MapParser(MapParserConfig()) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (1024, 964)) + + +def test_benchmark_v1_s6_map_scale_4_default(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S6 map at default scale 4 (1644x1344 px).""" + raw_data = _V1_S6_MAP.read_bytes() + parser = MapParser(MapParserConfig(map_scale=4)) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (1644, 1344)) + + +def test_benchmark_v1_s6_map_scale_2(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S6 map at scale 2 (822x672 px).""" + raw_data = _V1_S6_MAP.read_bytes() + parser = MapParser(MapParserConfig(map_scale=2)) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (822, 672)) + + +def test_benchmark_v1_s6_map_scale_1(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S6 map at scale 1 (411x336 px).""" + raw_data = _V1_S6_MAP.read_bytes() + parser = MapParser(MapParserConfig(map_scale=1)) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (411, 336)) + + +def test_benchmark_v1_s6_map_no_drawables(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S6 map with all drawables disabled.""" + raw_data = _V1_S6_MAP.read_bytes() + parser = MapParser(MapParserConfig(map_scale=4, drawables=[])) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (1644, 1344)) + + +def test_benchmark_v1_s6_map_all_drawables(benchmark: BenchmarkFixture) -> None: + """Benchmark parsing a real Roborock V1 S6 map with all available drawables enabled.""" + raw_data = _V1_S6_MAP.read_bytes() + parser = MapParser(MapParserConfig(map_scale=4, drawables=list(Drawable))) + result = benchmark(parser.parse, raw_data) + _validate_image(result, (1644, 1344)) + + +# --------------------------------------------------------------------------- +# Q10 Map Benchmarks +# --------------------------------------------------------------------------- + + +def test_benchmark_q10_map_packet_unpack(benchmark: BenchmarkFixture) -> None: + """Benchmark Q10 wire packet decompression and metadata decoding.""" + raw_data = _Q10_MAP.read_bytes() + packet = benchmark(parse_map_packet, raw_data) + assert packet.width > 0 + assert packet.height > 0 + + +def test_benchmark_q10_map_parse_and_render(benchmark: BenchmarkFixture) -> None: + """Benchmark end-to-end Q10 small map packet decoding and PNG rendering.""" + raw_data = _Q10_MAP.read_bytes() + parser = B01Q10MapParser() + result = benchmark(parser.parse, raw_data) + _validate_image(result, (32, 24)) + + +def test_benchmark_q10_map_full_scale_render_scale_1(benchmark: BenchmarkFixture) -> None: + """Benchmark Q10 composite rendering on a realistic 200x200 4-room floorplan at scale 1.""" + packet = _build_synthetic_q10_grid(200, 200) + parser = B01Q10MapParser(B01Q10MapParserConfig(map_scale=1)) + result = benchmark(parser.parse_packet, packet) + _validate_image(result, (200, 200)) + + +def test_benchmark_q10_map_full_scale_render_scale_4(benchmark: BenchmarkFixture) -> None: + """Benchmark Q10 composite rendering on a realistic 200x200 4-room floorplan at scale 4.""" + packet = _build_synthetic_q10_grid(200, 200) + parser = B01Q10MapParser(B01Q10MapParserConfig(map_scale=4)) + result = benchmark(parser.parse_packet, packet) + _validate_image(result, (800, 800)) + + +# --------------------------------------------------------------------------- +# Standalone CLI Benchmark & Profiler +# --------------------------------------------------------------------------- + + +def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None: + v1_s5_data = _V1_S5_MAP.read_bytes() + v1_s6_data = _V1_S6_MAP.read_bytes() + q10_data = _Q10_MAP.read_bytes() + q10_packet_200 = _build_synthetic_q10_grid(200, 200) + + p_v1_s5 = MapParser(MapParserConfig()) + p_v1_s6_scale4 = MapParser(MapParserConfig(map_scale=4)) + p_v1_s6_scale2 = MapParser(MapParserConfig(map_scale=2)) + p_v1_s6_scale1 = MapParser(MapParserConfig(map_scale=1)) + p_v1_s6_no_draw = MapParser(MapParserConfig(map_scale=4, drawables=[])) + p_v1_s6_all_draw = MapParser(MapParserConfig(map_scale=4, drawables=list(Drawable))) + + p_q10_small = B01Q10MapParser() + p_q10_scale1 = B01Q10MapParser(B01Q10MapParserConfig(map_scale=1)) + p_q10_scale4 = B01Q10MapParser(B01Q10MapParserConfig(map_scale=4)) + + benchmarks: list[tuple[str, Callable[[], Any]]] = [ + ("V1 S5 Map (Scale 4, Default)", lambda: p_v1_s5.parse(v1_s5_data)), + ("V1 S6 Map (Scale 4, Default)", lambda: p_v1_s6_scale4.parse(v1_s6_data)), + ("V1 S6 Map (Scale 2)", lambda: p_v1_s6_scale2.parse(v1_s6_data)), + ("V1 S6 Map (Scale 1)", lambda: p_v1_s6_scale1.parse(v1_s6_data)), + ("V1 S6 Map (No Drawables)", lambda: p_v1_s6_no_draw.parse(v1_s6_data)), + ("V1 S6 Map (All Drawables)", lambda: p_v1_s6_all_draw.parse(v1_s6_data)), + ("Q10 Wire Packet (Unpack only)", lambda: parse_map_packet(q10_data)), + ("Q10 Small Map (Scale 1)", lambda: p_q10_small.parse(q10_data)), + ("Q10 200x200 Map (Scale 1)", lambda: p_q10_scale1.parse_packet(q10_packet_200)), + ("Q10 200x200 Map (Scale 4)", lambda: p_q10_scale4.parse_packet(q10_packet_200)), + ] + + print(f"\nRunning {len(benchmarks)} benchmarks ({warmup} warmup, {iterations} timed iterations)...\n") + results = [] + + for name, fn in benchmarks: + for _ in range(warmup): + fn() + + timings: list[float] = [] + for _ in range(iterations): + start = time.perf_counter() + fn() + timings.append((time.perf_counter() - start) * 1000.0) + + timings.sort() + min_ms = timings[0] + mean_ms = sum(timings) / len(timings) + med_ms = timings[len(timings) // 2] + p95_ms = timings[int(len(timings) * 0.95)] + ops_per_sec = 1000.0 / mean_ms if mean_ms > 0 else float("inf") + + results.append((name, min_ms, med_ms, mean_ms, p95_ms, ops_per_sec)) + + if profile: + pr = cProfile.Profile() + pr.enable() + for _ in range(iterations): + fn() + pr.disable() + s = io.StringIO() + ps = pstats.Stats(pr, stream=s).sort_stats(pstats.SortKey.CUMULATIVE) + ps.print_stats(10) + print(f"--- cProfile Top Hotspots: {name} ---") + print(s.getvalue()) + + header = ( + f"{'Benchmark':<36} | {'Min (ms)':>9} | {'Med (ms)':>9} | {'Mean (ms)':>9} | {'P95 (ms)':>9} | {'Ops/sec':>10}" + ) + print(header) + print("-" * len(header)) + for name, min_ms, med_ms, mean_ms, p95_ms, ops in results: + print(f"{name:<36} | {min_ms:9.3f} | {med_ms:9.3f} | {mean_ms:9.3f} | {p95_ms:9.3f} | {ops:10.1f}") + print() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run Roborock map parser benchmarks") + parser.add_argument("--iterations", type=int, default=20, help="Number of benchmark iterations (default: 20)") + parser.add_argument("--warmup", type=int, default=3, help="Number of warmup iterations (default: 3)") + parser.add_argument("--profile", action="store_true", help="Print cProfile hotspot breakdown for each benchmark") + args = parser.parse_args() + + if args.iterations <= 0: + parser.error("--iterations must be greater than 0") + if args.warmup < 0: + parser.error("--warmup cannot be negative") + + _run_benchmarks(iterations=args.iterations, warmup=args.warmup, profile=args.profile) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 929c4420..4ee80f4a 100644 --- a/uv.lock +++ b/uv.lock @@ -1191,6 +1191,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] +[[package]] +name = "py-cpuinfo2" +version = "10.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/97/a8b1ddada14c8280a047c0746f95cb05d94a31b1a331cea22bcdc2b2a82d/py_cpuinfo2-10.1.1.tar.gz", hash = "sha256:7861133863663f16e06eca63b12904ef100b5760415e92372dac0162799a4771", size = 100840, upload-time = "2026-03-25T21:49:40.797Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/0a/ba69d2dde1ae12ef1d389ea5a216384c5ff6ef7a1e7a48d1e9b6686f6790/py_cpuinfo2-10.1.1-py3-none-any.whl", hash = "sha256:adc53396bfb206e6498d078ec2ab407f85799ecd819584ac36a8f80a2d4d762d", size = 23791, upload-time = "2026-03-25T21:49:39.574Z" }, +] + [[package]] name = "pycryptodome" version = "3.23.0" @@ -1295,6 +1304,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo2" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8f/83a15e40dbc34a580ee56eb56983cae5394c6e94d50cf28fe268e457be25/pytest_benchmark-5.3.0.tar.gz", hash = "sha256:358444d4e89be901ee2b6404fb043ac3d7684002ad7f3563cc153fca6339c965", size = 375410, upload-time = "2026-08-23T17:45:08.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/42/7e80f7cfa191e0a766d1de99b4661847415ad5db34f8209d81fd42175b59/pytest_benchmark-5.3.0-py3-none-any.whl", hash = "sha256:920ab1dfcffa718d49aa15ba144c7e357bda59216a0dc308016cc1c7236f719d", size = 48401, upload-time = "2026-08-23T17:45:07.094Z" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -1368,6 +1390,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "python-roborock", extra = ["cli"] }, @@ -1405,6 +1428,7 @@ dev = [ { name = "pre-commit", specifier = ">=3.5,<5.0" }, { name = "pytest" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, + { name = "pytest-benchmark", specifier = ">=5.1.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-timeout", specifier = ">=2.3.1,<3" }, { name = "python-roborock", extras = ["cli"] },