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
31 changes: 29 additions & 2 deletions roborock/devices/traits/b01/q10/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
from dataclasses import dataclass, field
from typing import Any

from vacuum_map_parser_base.map_data import MapData

from roborock.data import RoborockBase
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXDeviceState
from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
from roborock.devices.traits.common import DpsDataConverter, TraitUpdateListener
from roborock.exceptions import RoborockException
from roborock.map.b01_grid_layers import GridCalibration
from roborock.map.b01_q10_map_parser import (
B01Q10MapParserConfig,
Q10MapPacket,
Expand All @@ -30,7 +33,12 @@
Q10TracePacket,
)
from roborock.map.b01_q10_overlays import parse_virtual_wall_blob, parse_zone_blob
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
from roborock.map.b01_q10_render import (
Q10MapOverlays,
build_base_q10_map_data,
render_q10_map,
solve_q10_calibration,
)

from .command import CommandTrait
from .common import UpdatableTrait
Expand Down Expand Up @@ -105,6 +113,8 @@ def __init__(
self._map_packet: Q10MapPacket | None = None
self._trace_packet: Q10TracePacket | None = None
self._image_content: bytes | None = None
self._base_map_data: MapData | None = None
self._trace_calibration: GridCalibration | None = None
self._map_dps.add_update_listener(self._map_dps_updated)

async def refresh(self) -> None:
Expand Down Expand Up @@ -151,6 +161,8 @@ def robot_heading(self) -> int | None:
def update_from_map_packet(self, packet: Q10MapPacket) -> None:
"""Store a map-protocol update and render the latest sources."""
self._map_packet = packet
self._base_map_data = None
self._trace_calibration = None
self._render()
self._notify_update()

Expand All @@ -164,6 +176,8 @@ def _map_dps_updated(self) -> None:
"""Render after the low-level map DPS source changes."""
if self._map_packet is None:
return
self._base_map_data = None
self._trace_calibration = None
self._render()
self._notify_update()

Expand All @@ -172,12 +186,25 @@ def _render(self) -> None:
if self._map_packet is None:
return
try:
trace = self._trace_packet if not self._map_dps.robot_at_dock else None
if self._trace_calibration is None and trace is not None:
self._trace_calibration = solve_q10_calibration(self._map_packet, trace)
if self._trace_calibration is not None and self._map_packet.erase_zones:
self._base_map_data = None
if self._base_map_data is None:
self._base_map_data = build_base_q10_map_data(
self._map_packet,
config=self._config,
trace_calibration=self._trace_calibration,
)
self._image_content = render_q10_map(
self._map_packet,
self._trace_packet if not self._map_dps.robot_at_dock else None,
trace,
self._map_dps.overlays,
config=self._config,
robot_at_dock=self._map_dps.robot_at_dock,
base_map_data=self._base_map_data,
trace_calibration=self._trace_calibration,
)
except RoborockException as ex:
_LOGGER.debug("Failed to render Q10 map packet: %s", ex)
Expand Down
3 changes: 2 additions & 1 deletion roborock/map/b01_q10_map_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import statistics
import struct
from dataclasses import dataclass, field, replace
from functools import cached_property

from PIL import Image
from vacuum_map_parser_base.config.color import ColorsPalette, SupportedColor
Expand Down Expand Up @@ -238,7 +239,7 @@ class Q10MapPacket:
the same (top-down) pixel space as :attr:`grid`, where a non-zero cell is
carpet (the value is the carpet kind). ``None`` if the packet carried none."""

@property
@cached_property
def layers(self) -> GridLayers:
"""Split the occupancy grid into separable grid-pixel layers."""
rooms = [(room.id, room.name, room.pixel_value, room.pixel_count) for room in self.rooms]
Expand Down
40 changes: 29 additions & 11 deletions roborock/map/b01_q10_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
drawing) and the calibration policy live here, next to the rest of the map code.
"""

import copy
import io
import math
from collections.abc import Sequence
Expand Down Expand Up @@ -82,13 +83,32 @@ class Q10MapOverlays:
virtual_walls: Sequence[Q10Zone] = ()


def build_base_q10_map_data(
packet: Q10MapPacket,
*,
config: B01Q10MapParserConfig,
trace_calibration: GridCalibration | None = None,
) -> MapData:
"""Build reusable MapData containing the rendered base grid image."""
parser = B01Q10MapParser(config)
vector_calibration = _vector_calibration(packet, trace_calibration)
render_packet = packet
if vector_calibration is not None:
cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration)
if cells:
render_packet = erased_packet(packet, cells)
return parser.map_data_from_packet(render_packet)


def render_q10_map(
packet: Q10MapPacket,
trace: Q10TracePacket | Q10HistoricalTracePacket | None,
overlays: Q10MapOverlays,
*,
config: B01Q10MapParserConfig,
robot_at_dock: bool = False,
base_map_data: MapData | None = None,
trace_calibration: GridCalibration | None = None,
) -> bytes:
"""Compose the latest map, trace and DPS inputs into one PNG image.

Expand All @@ -97,22 +117,20 @@ def render_q10_map(
available trace and DPS overlays are projected and drawn in pixel space.
Raises :class:`RoborockException` if map rendering fails.
"""
parser = B01Q10MapParser(config)
trace_calibration = solve_q10_calibration(packet, trace)
if trace_calibration is None:
trace_calibration = solve_q10_calibration(packet, trace)
vector_calibration = _vector_calibration(packet, trace_calibration)

render_packet = packet
if vector_calibration is not None:
cells = _erased_cells(packet.layers, packet.erase_zones, vector_calibration)
if cells:
# Blank the erase-zone cells before parsing the raster so phantom
# areas disappear (as the app shows).
render_packet = erased_packet(packet, cells)
if base_map_data is None:
base_map_data = build_base_q10_map_data(packet, config=config, trace_calibration=trace_calibration)

map_data = parser.map_data_from_packet(render_packet)
if map_data.image is None:
if base_map_data.image is None:
raise RoborockException("Failed to render Q10 map image")

map_data = copy.copy(base_map_data)
map_data.image = copy.copy(base_map_data.image)
map_data.image.data = base_map_data.image.data.copy()

has_drawables = False
if trace_calibration is not None and trace is not None:
charger_heading = packet.header_calibration.charger_phi if packet.header_calibration is not None else None
Expand Down
41 changes: 41 additions & 0 deletions tests/devices/traits/b01/q10/test_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,47 @@ def test_render_failure_clears_stale_image(render_map: Mock) -> None:
assert trait.image_content is None


def test_trace_updates_reuse_cached_base_map_and_calibration() -> None:
"""Subsequent trace updates reuse the rendered base map without rebuilding it."""
map_dps = MapDpsTrait()
trait = _map_trait(map_dps)
packet = parse_map_packet(FIXTURE.read_bytes())
trace1 = parse_trace_packet(TRACE_SESSION_FIXTURE.read_bytes())
trace2 = Q10TracePacket(points=[*trace1.points, Q10Point(100, 100)], sequence=trace1.sequence + 1)

with (
patch(
"roborock.devices.traits.b01.q10.map.build_base_q10_map_data",
wraps=None,
) as mock_build_base,
patch(
"roborock.devices.traits.b01.q10.map.solve_q10_calibration",
wraps=None,
) as mock_solve_cal,
patch("roborock.devices.traits.b01.q10.map.render_q10_map", return_value=b"png") as mock_render,
):
mock_build_base.return_value = Mock()
mock_solve_cal.return_value = Mock()

trait.update_from_map_packet(packet)
assert mock_build_base.call_count == 1
assert mock_solve_cal.call_count == 0

trait.update_from_trace_packet(trace1)
assert mock_build_base.call_count == 1
assert mock_solve_cal.call_count == 1

trait.update_from_trace_packet(trace2)
assert mock_build_base.call_count == 1
assert mock_solve_cal.call_count == 1
assert mock_render.call_count == 3

# A DPS update or new map packet invalidates the cached base map.
map_dps.update_from_dps({B01_Q10_DP.RESTRICTED_ZONE_UP: _zone_blob()})
assert mock_build_base.call_count == 2
assert mock_solve_cal.call_count == 2


# --- Overlays ----------------------------------------------------------------


Expand Down
41 changes: 40 additions & 1 deletion tests/map/test_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@
parse_map_packet,
parse_trace_packet,
)
from roborock.map.b01_q10_render import Q10MapOverlays, render_q10_map
from roborock.map.b01_q10_render import (
Q10MapOverlays,
build_base_q10_map_data,
render_q10_map,
solve_q10_calibration,
)
from roborock.map.map_parser import MapParser, MapParserConfig, ParsedMapData

if TYPE_CHECKING:
Expand Down Expand Up @@ -218,6 +223,27 @@ def test_benchmark_q10_map_render_with_trace(benchmark: BenchmarkFixture) -> Non
_validate_image(result, (32, 24))


def test_benchmark_q10_map_render_with_trace_cached(benchmark: BenchmarkFixture) -> None:
"""Benchmark Q10 render_q10_map on trace updates using cached base MapData and calibration."""
map_bytes = _Q10_MAP.read_bytes()
trace_bytes = _Q10_TRACE.read_bytes()
packet = parse_map_packet(map_bytes)
trace = parse_trace_packet(trace_bytes)
config = B01Q10MapParserConfig(map_scale=4)
trace_calibration = solve_q10_calibration(packet, trace)
base_map_data = build_base_q10_map_data(packet, config=config, trace_calibration=trace_calibration)
result = benchmark(
render_q10_map,
packet,
trace=trace,
overlays=Q10MapOverlays(),
config=config,
base_map_data=base_map_data,
trace_calibration=trace_calibration,
)
_validate_image(result, (32, 24))


# ---------------------------------------------------------------------------
# Standalone CLI Benchmark & Profiler
# ---------------------------------------------------------------------------
Expand All @@ -244,6 +270,8 @@ def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None:
q10_map_packet = parse_map_packet(q10_data)
q10_trace_packet = parse_trace_packet(q10_trace_data)
q10_render_config = B01Q10MapParserConfig(map_scale=4)
q10_trace_cal = solve_q10_calibration(q10_map_packet, q10_trace_packet)
q10_base_map = build_base_q10_map_data(q10_map_packet, config=q10_render_config, trace_calibration=q10_trace_cal)

benchmarks: list[tuple[str, Callable[[], Any]]] = [
("V1 S5 Map (Scale 4, Default)", lambda: p_v1_s5.parse(v1_s5_data)),
Expand All @@ -265,6 +293,17 @@ def _run_benchmarks(iterations: int, warmup: int, profile: bool) -> None:
config=q10_render_config,
),
),
(
"Q10 Render with Trace (Cached)",
lambda: render_q10_map(
q10_map_packet,
trace=q10_trace_packet,
overlays=Q10MapOverlays(),
config=q10_render_config,
base_map_data=q10_base_map,
trace_calibration=q10_trace_cal,
),
),
]

print(f"\nRunning {len(benchmarks)} benchmarks ({warmup} warmup, {iterations} timed iterations)...\n")
Expand Down
Loading