diff --git a/meshterm/core/mvt.py b/meshterm/core/mvt.py index 3159b9a..1211c8b 100644 --- a/meshterm/core/mvt.py +++ b/meshterm/core/mvt.py @@ -16,6 +16,7 @@ from __future__ import annotations import gzip import marshal +import struct from collections.abc import Container from dataclasses import dataclass, field from typing import Any @@ -391,3 +392,52 @@ def loads_layers(blob: bytes, *, stamp: str = "") -> list[Layer] | None: ] except Exception: # noqa: BLE001 - any malformed blob is simply a cache miss return None + + +# -- what a decoded tile weighs ------------------------------------------------------ + +#: What a decoded tile costs in RAM, in pointer widths: per point (its ``(x, y)`` tuple, +#: the ints in it, its slot in the ring), per feature (the object, its tag dict, its ring +#: lists), and per tag. Fitted by least squares against ``tracemalloc`` over every tile in +#: the PicoCalc's cache (32-bit) and the same tiles decoded on a 64-bit desktop, which came +#: out at the same counts of words to within a few percent: 15-16 a point, 42-44 a +#: feature, 1.5-2 a tag. +_POINT_WORDS = 16 +_FEATURE_WORDS = 44 +_TAG_WORDS = 2 + +#: A pointer, in bytes — the unit the above are counted in. A tag's string *value* costs +#: half of one per UTF-8 byte (2.1 bytes measured on the device, 3.4 on the desktop). +_WORD = struct.calcsize("P") + + +def resident_bytes(layers: list[Layer]) -> int: + """Roughly how much RAM ``layers`` hold, for a cache that must budget in bytes. + + A tile's weight is no use counted in *tiles*: on the PicoCalc a decoded tile runs from + 1 MB (a suburb at z10) to 4.6 MB (a coastline at z7), and a cache bounded by count held + 50-odd MB of them on a 100 MB device, which ended in the SD-card swap — the map frozen + for fifteen seconds while the kernel paged the interpreter back in. + + Points alone are not the measure either: a zoomed-out tile is mostly place labels, each + carrying its name in dozens of languages, and a z2 tile weighing 1.6 MB has almost no + geometry at all. So the tags count too, and their text. The walk never touches a + coordinate, so it is cheap beside the decode that produced the layers. + + An estimate: on the device's 157 cached tiles it lands from 1% under to 9% over what + ``tracemalloc`` measures, and from 6% under to 21% over on a 64-bit desktop. + """ + points = features = tags = text = 0 + for layer in layers: + for feat in layer.features: + features += 1 + tags += len(feat.tags) + for value in feat.tags.values(): + if isinstance(value, str): + # Bytes, not characters: a name in another script is stored wider, + # and those are what fill a zoomed-out tile. + text += len(value.encode()) + for ring in feat.rings: + points += len(ring) + words = points * _POINT_WORDS + features * _FEATURE_WORDS + tags * _TAG_WORDS + return words * _WORD + text * _WORD // 2 diff --git a/meshterm/services/basemap.py b/meshterm/services/basemap.py index 4e1a06d..640c2c5 100644 --- a/meshterm/services/basemap.py +++ b/meshterm/services/basemap.py @@ -37,6 +37,8 @@ can't decode are pruned on read, which heals a cache already poisoned this way. Fetches are blocking (stdlib ``urllib``); callers on an event loop should run :meth:`BasemapSource.load_tile` via ``asyncio.to_thread`` so the UI stays responsive. +:meth:`BasemapSource.resident` is the one question that never blocks — what is already in +RAM — and the only one a paint may ask. """ from __future__ import annotations @@ -56,7 +58,7 @@ from pathlib import Path from typing import NamedTuple from .. import __version__ -from ..core.mvt import Layer, decode_tile, dumps_layers, loads_layers +from ..core.mvt import Layer, decode_tile, dumps_layers, loads_layers, resident_bytes #: OpenFreeMap planet TileJSON — its ``tiles`` array holds the current versioned template. DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet" @@ -96,10 +98,31 @@ _DEFAULT_MAX_DECODED_BYTES = 64 * 1024 * 1024 #: the directory, which is slow on the SD card the PicoCalc runs from. _PRUNE_AFTER_BYTES = 8 * 1024 * 1024 -#: Decoded tiles held in RAM. A viewport spans at most a handful of tiles, so this covers -#: the view plus the ring a pan or a zoom step reaches into, and little more — the PicoCalc -#: has 100 MB of RAM in total and decoded layers are not small. -_MEMO_TILES = 24 +#: The share of the machine's memory that decoded tiles may hold — one part in this many. +#: Budgeted in bytes because a tile's weight varies fourfold (see +#: :func:`~meshterm.core.mvt.resident_bytes`), and scaled to the machine because the one +#: running it may be a desktop or a 100 MB handheld: there an eighth is ~13 MB, the view +#: and a pan's worth of history, and the rest of the RAM stays free for the interpreter. +#: Overrunning it is not a slow map but a stopped one — the PicoCalc swaps to its SD card, +#: and a process whose code pages are being read back off it does nothing else meanwhile. +_MEMO_SHARE = 8 + +#: The budget's bounds: a floor so a small machine still keeps a view's worth, and a +#: ceiling past which more history buys a desktop nothing it would notice. A machine that +#: cannot say how much memory it has (Windows has no ``sysconf``) gets the ceiling. +_MEMO_FLOOR = 8 * 1024 * 1024 +_MEMO_CEILING = 128 * 1024 * 1024 + + +def _memo_budget() -> int: + """How many bytes of decoded tiles this machine can hold in RAM (see :data:`_MEMO_SHARE`).""" + try: + physical = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") + except (AttributeError, ValueError, OSError): + return _MEMO_CEILING + if physical <= 0: + return _MEMO_CEILING + return max(_MEMO_FLOOR, min(_MEMO_CEILING, physical // _MEMO_SHARE)) #: How long a failed TileJSON resolve stands before the source will ask again. The PicoCalc #: brings its Wi-Fi up some 40 seconds into the boot, well after the app it was started @@ -197,6 +220,7 @@ class BasemapSource: timeout: float = 12.0, layers: Container[str] | None = None, max_decoded_bytes: int = _DEFAULT_MAX_DECODED_BYTES, + memo_bytes: int | None = None, ) -> None: """Open a tile source backed by an on-disk cache. @@ -210,6 +234,8 @@ class BasemapSource: later renderer can widen without re-fetching anything. max_decoded_bytes: Ceiling on the decoded sidecar cache, which is derived data and so is the one part of the cache that can be thrown away freely. + memo_bytes: Ceiling on the decoded tiles held in RAM; ``None`` sizes it to the + machine (see :data:`_MEMO_SHARE`). """ self.cache_dir = cache_dir self._tilejson_url = tilejson_url @@ -229,12 +255,16 @@ class BasemapSource: # than on disk so the claim expires with the process: a blank tile is cheap to # re-ask about, and a stale one on disk is a permanent hole in the map. self._blank: set[tuple[int, int, int]] = set() - # Decoded layers kept in RAM, most-recently-used last. The sidecar already spares - # the protobuf decode, but a marshal load off the SD card is still ~96 ms on the - # PicoCalc — and a map paints its whole viewport's worth of tiles on *every* - # frame, so panning one dot re-read every tile that had not moved. Bounded because - # decoded layers are the fattest thing this class holds. - self._memo: OrderedDict[tuple[int, int, int], list[Layer]] = OrderedDict() + # Decoded layers kept in RAM with what each weighs, most-recently-used last. The + # sidecar already spares the protobuf decode, but a marshal load off the SD card is + # still ~96 ms on the PicoCalc — and a map paints its whole viewport's worth of + # tiles on *every* frame, so panning one dot re-read every tile that had not moved. + # Bounded in bytes because decoded layers are the fattest thing this class holds, + # and locked because the map's worker threads fill it while its paints read it. + self._memo: OrderedDict[tuple[int, int, int], tuple[list[Layer], int]] = OrderedDict() + self._memo_bytes = 0 + self._memo_budget = _memo_budget() if memo_bytes is None else memo_bytes + self._memo_lock = threading.Lock() # -- metadata --------------------------------------------------------------- @@ -356,24 +386,59 @@ class BasemapSource: The decoded layers, or ``None`` if the tile is unavailable (offline and uncached, or a genuinely empty/missing tile). """ - key = (z, x, y) - if key in self._blank: + if (z, x, y) in self._blank: return None - hot = self._memo.get(key) + hot = self.resident(z, x, y) if hot is not None: - self._memo.move_to_end(key) return hot - ready = self._read_decoded(z, x, y) - if ready is not None: - self._remember(key, ready) - return ready + layers = self._read_decoded(z, x, y) + if layers is None: + layers = self._build(z, x, y) + if layers is not None: + self._remember((z, x, y), layers) + return layers + + def resident(self, z: int, x: int, y: int) -> list[Layer] | None: + """A tile's decoded layers if they are already in RAM, else ``None`` — never I/O. + + The one question a paint may put to the source. A map moving back over ground it + has drawn would otherwise send every tile of it through a worker thread to fetch + what is sitting in memory, and draw its first frame without them while it waited. + """ + with self._memo_lock: + hit = self._memo.get((z, x, y)) + if hit is None: + return None + self._memo.move_to_end((z, x, y)) + return hit[0] + + def warm(self, z: int, x: int, y: int) -> None: + """Make a tile cheap to load later — fetched, decoded and written down — holding nothing. + + What a guess at the next move is for: the slow part of a tile nobody has looked at + yet is the network and the decode, and the sidecar on disk is the whole of the + answer to both. Holding the result in RAM as well spent the memory budget on tiles + that may never be looked at, evicting ones that were, so a tile that already has + its sidecar is passed over without being read. + """ + if (z, x, y) in self._blank or self.resident(z, x, y) is not None: + return + if self._decoded_path(z, x, y).exists(): + return + self._build(z, x, y) + + def _build(self, z: int, x: int, y: int) -> list[Layer] | None: + """Decode a tile from its raw bytes — cached, else fetched — writing both caches. + + Holds nothing in RAM; that is :meth:`load_tile`'s decision, not this one's. + """ + key = (z, x, y) path = self._tile_path(z, x, y) cached = self._read_cached(path) if cached is not None: layers = self._decode(cached, key) if layers is not None: self._write_decoded(z, x, y, layers) - self._remember(key, layers) return layers # Nothing drawable came out: a zero-byte marker from a build that cached # network failures, or bytes truncated by a link (or a power cut) mid-write. @@ -389,15 +454,24 @@ class BasemapSource: return None self._write_cached(path, raw) self._write_decoded(z, x, y, layers) - self._remember(key, layers) return layers def _remember(self, key: tuple[int, int, int], layers: list[Layer]) -> None: - """Hold a decoded tile in RAM, evicting the least recently used past the budget.""" - self._memo[key] = layers - self._memo.move_to_end(key) - while len(self._memo) > _MEMO_TILES: - self._memo.popitem(last=False) + """Hold a decoded tile in RAM, evicting the least recently used past the budget. + + The newest tile always stays, even alone over budget: it was loaded because a view + needs it, and evicting it would only send that view back to the disk for it. + """ + cost = resident_bytes(layers) + with self._memo_lock: + old = self._memo.pop(key, None) + if old is not None: + self._memo_bytes -= old[1] + self._memo[key] = (layers, cost) + self._memo_bytes += cost + while self._memo_bytes > self._memo_budget and len(self._memo) > 1: + _, (_, dropped) = self._memo.popitem(last=False) + self._memo_bytes -= dropped # -- the decoded sidecar ---------------------------------------------------- diff --git a/meshterm/ui/map_render.py b/meshterm/ui/map_render.py index b997113..1b8d4bf 100644 --- a/meshterm/ui/map_render.py +++ b/meshterm/ui/map_render.py @@ -25,6 +25,7 @@ from __future__ import annotations import math from collections import Counter from dataclasses import dataclass, field +from operator import itemgetter from ..core.geo import Viewport from ..core.models import MapMarker @@ -432,6 +433,10 @@ def _compose( return canvas +#: A point's y, for ``min``/``max`` to read in C (see ``on_view`` in :func:`_draw_tile`). +_Y = itemgetter(1) + + def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> None: """Draw one decoded tile's geometry and collect its label candidates.""" vp = frame.canvas @@ -444,13 +449,44 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No # vertex is worth removing. transforms: dict[int, tuple[float, float, float]] = {} - def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]: + def transform(extent: int) -> tuple[float, float, float]: t = transforms.get(extent) if t is None: t = transforms[extent] = frame.viewport.tile_transform(x, y, z, extent) - bx, by, step = t + return t + + def project(ring: list[tuple[int, int]], extent: int) -> list[tuple[float, float]]: + bx, by, step = transform(extent) return [(bx + lx * step, by + ly * step) for lx, ly in ring] + # The canvas as a window in this tile's own units, a dot wider all round, so a ring can + # be tested before a single point of it is projected. A zoomed-out view sees a sliver + # of tiles whose coastlines and land cover run to tens of thousands of points, and + # projecting all of them was half of a z7 frame. Skipping a ring wholly outside is + # exact, never an approximation: a line there lights nothing, and a closed ring's + # crossings on every scanline come in pairs off the canvas, so the even-odd fill of + # whatever it shares a polygon with is unchanged (see MapCanvas.fill_polygon). + windows: dict[int, tuple[float, float, float, float]] = {} + + def on_view(ring: list[tuple[int, int]], extent: int) -> bool: + w = windows.get(extent) + if w is None: + bx, by, step = transform(extent) + w = windows[extent] = ( + (-1 - bx) / step, + (vp.dot_w + 1 - bx) / step, + (-1 - by) / step, + (vp.dot_h + 1 - by) / step, + ) + lo_x, hi_x, lo_y, hi_y = w + # min/max over the tuples run in C: the first element decides, so they are x. + return not ( + max(ring)[0] < lo_x + or min(ring)[0] > hi_x + or max(ring, key=_Y)[1] < lo_y + or min(ring, key=_Y)[1] > hi_y + ) + # A tile's palette is a handful of colours shared by tens of thousands of features, so # every one of them is resolved once here rather than per feature. mark_rgb memoizes, # but at ~30 000 calls a frame even a dict hit behind a function call is real time on @@ -477,37 +513,20 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No rgb, prio = green_rgb, green_prio else: continue - vp.fill_polygon([project(r, layer.extent) for r in feat.rings], rgb, prio) + ext = layer.extent + vp.fill_polygon([project(r, ext) for r in feat.rings if on_view(r, ext)], rgb, prio) # Buildings, as a stippled texture under the streets. building = by_name.get("building") if building is not None and frame.viewport.zoom >= _BUILDING_MIN_ZOOM and not frame.coarse: rgb = mark_rgb(_BUILDING_FILL[0]) - # One tile-local unit is a fixed number of dots, so the visible slab of the tile - # is a rectangle in tile coordinates: work out its bounds once and reject each - # footprint on its own bounds before projecting a single point. A downtown tile - # holds thousands of footprints and a zoomed-in view shows a few dozen of them. + # A downtown tile holds thousands of footprints and a zoomed-in view shows a few + # dozen of them, so each is rejected on its own bounds before it is projected. ext = building.extent - x0, y0 = frame.viewport.feature_to_dot(x, y, z, ext, 0, 0) - x1, y1 = frame.viewport.feature_to_dot(x, y, z, ext, ext, ext) - if x1 != x0 and y1 != y0: - lo_x, hi_x = sorted((-x0 * ext / (x1 - x0), (vp.dot_w - x0) * ext / (x1 - x0))) - lo_y, hi_y = sorted((-y0 * ext / (y1 - y0), (vp.dot_h - y0) * ext / (y1 - y0))) - else: # a degenerate projection can't be culled against; draw it all. - lo_x = lo_y = -math.inf - hi_x = hi_y = math.inf for feat in building.features: if feat.geom_type != GEOM_POLYGON: continue - keep = [] - for ring in feat.rings: - if len(ring) < 4: - continue - rxs = [p[0] for p in ring] - rys = [p[1] for p in ring] - if max(rxs) < lo_x or min(rxs) > hi_x or max(rys) < lo_y or min(rys) > hi_y: - continue - keep.append(ring) + keep = [ring for ring in feat.rings if len(ring) >= 4 and on_view(ring, ext)] if keep: vp.fill_polygon( [project(r, ext) for r in keep], @@ -530,7 +549,7 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No continue rgb, prio = style for ring in feat.rings: - if len(ring) >= 2: + if len(ring) >= 2 and on_view(ring, waterway.extent): vp.draw_line(project(ring, waterway.extent), rgb, prio) if feat.name and not frame.coarse: _add_line_label( @@ -555,7 +574,7 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No if frame.coarse and prio < _COARSE_ROAD_PRIORITY: continue # the back streets are the bulk of the lines and the last to matter for ring in feat.rings: - if len(ring) >= 2: + if len(ring) >= 2 and on_view(ring, transportation.extent): vp.draw_line(project(ring, transportation.extent), rgb, prio) # Boundaries (admin) as a faint hint. @@ -570,7 +589,7 @@ def _draw_tile(frame: _Frame, layers: list[Layer], z: int, x: int, y: int) -> No except (TypeError, ValueError): continue for ring in feat.rings: - if len(ring) >= 2: + if len(ring) >= 2 and on_view(ring, boundary.extent): vp.draw_line(project(ring, boundary.extent), boundary_rgb, 14) if frame.coarse: diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index d82061d..637e0e9 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -80,17 +80,13 @@ def _loop_running() -> bool: return True -#: How many screens' worth of decoded tiles to keep resident, as a multiple of what the -#: current view needs. A decoded tile costs ~0.9 MB on the PicoCalc (62 bytes a point, -#: measured) against a device that has ~100 MB in total, so a map panned far enough would -#: otherwise fill memory with ground the user has left behind. Keeping the screen plus one -#: screen of history costs almost nothing to get wrong: an evicted tile comes back from -#: the decoded cache in ~25-50 ms, having already been parsed once. -_TILE_CACHE_SCREENS = 2 - -#: Floor on that budget, so a zoomed-out view needing one or two tiles still keeps enough -#: history for a pan away and back to be instant. -_MIN_TILE_CACHE = 8 +#: How many tiles may be loading at once. A load is a network fetch (waiting, which threads +#: overlap well) then a decode (pure Python, which they don't — the GIL runs one at a time, +#: and each holds the tile's whole decoded weight while it does). Two lets a fetch overlap a +#: decode; more only multiplies what is in memory at the moment the reader is moving +#: fastest, and spends the turns the paint needs. It is also what gives a fast pan's +#: abandoned tiles a queue to be dropped from (see :meth:`MapScreen._load`). +_TILE_LOADS = 2 #: Whether a moved view is answered with the **rough first pass** before its finished #: frame — the ground fills, the watercourses and the through-roads, in a quarter of the @@ -245,10 +241,12 @@ class MapScreen(Screen): #: zooming" clause of OSMF's guideline. A visit's worth of state, not a session's: #: a map reopened is a map arrived at again. self._untouched = True - # Decoded tiles, least-recently-shown first — see :meth:`_trim_tiles`. A stored + # The view's decoded tiles, and nothing more — see :meth:`_trim_tiles`. A stored # ``None`` is the source's own answer that there is nothing at those coordinates. - self._tiles: OrderedDict[tuple[int, int, int], list[Layer] | None] = OrderedDict() + self._tiles: dict[tuple[int, int, int], list[Layer] | None] = {} self._pending: set[tuple[int, int, int]] = set() + # The turns tile loads take (see :data:`_TILE_LOADS`), shared with the prefetcher. + self._tile_gate = asyncio.Semaphore(_TILE_LOADS) # Tiles the source gave no answer about, and when each may be asked for again — # a cooldown, not a verdict (see :meth:`_load`). self._unanswered: dict[tuple[int, int, int], float] = {} @@ -704,12 +702,18 @@ class MapScreen(Screen): :data:`~meshterm.services.basemap.TILE_RETRY_SECONDS` in :attr:`_unanswered` and is then asked for again — see :meth:`_load` for why that isn't the same as a tile it answered "nothing here" about. + + A tile the source already holds in RAM is taken here and now, with no thread + between: ground panned back onto is drawn in the very raster the move asks for, + rather than drawn without it first and again once a round trip to memory returned. """ wanted = vp.tiles(self._max_tile_zoom) + self._trim_tiles(wanted) for t in wanted: - if t in self._tiles: - self._tiles.move_to_end(t) # on screen now, so last in line to be dropped - self._trim_tiles(len(wanted)) + if t not in self._tiles: + layers = self._source.resident(*t) + if layers is not None: + self._tiles[t] = layers if not _loop_running(): # nothing to fetch onto — a static render draws what it has return self._expire_cooldowns() @@ -878,48 +882,54 @@ class MapScreen(Screen): self._speculated.popitem(last=False) async def _prefetch(self, tile: tuple[int, int, int], vp: Viewport) -> None: - """Warm one tile into the source's cache, then take the next guess. + """Warm one tile into the source's disk cache, then take the next guess. - The result is thrown away on purpose. A prefetched tile is not this screen's to - hold — :attr:`_tiles` is the view's working set and has a budget sized to it — and - it does not need to be: the fetch has written the tile and its decoded form to - disk and left it in the source's resident memo, so the paint that finally wants it - gets it in tens of milliseconds instead of a second and a half. + Disk, not memory (:meth:`~meshterm.services.basemap.BasemapSource.warm`). A + prefetched tile is not this screen's to hold — :attr:`_tiles` is the view's working + set — and not the source's RAM's either: that budget is the history of where the + reader *has* been, and twelve guesses at up to 4.6 MB each would evict all of it + for ground they may never visit. What a guess saves is the network and the decode, + and the sidecar on disk is the whole of both, so the paint that finally wants the + tile reads it back in a tenth of a second instead of waiting out a second and a + half. A tile whose sidecar is already written costs the guess nothing at all. + + It takes a turn at the tile gate like any load (:data:`_TILE_LOADS`), so a reader + who moves while it runs still has a turn waiting for the tiles they moved onto. No repaint either way: nothing on screen changed, and the whole point of doing this early was to not spend the user's time. """ - try: - await asyncio.to_thread(self._source.load_tile, *tile) - except Exception: # noqa: BLE001 - a guess that didn't pay off is not an error - pass + async with self._tile_gate: + try: + await asyncio.to_thread(self._source.warm, *tile) + except Exception: # noqa: BLE001 - a guess that didn't pay off is not an error + pass self._speculating = False if self._viewport is vp: # still the same view, so the same plan: keep going self._ensure_prefetch(vp) - def _trim_tiles(self, in_view: int) -> None: - """Release the least recently shown tiles once the view's budget is exceeded. + def _trim_tiles(self, wanted: list[tuple[int, int, int]]) -> None: + """Let go of every tile holding geometry that the view no longer shows. - Only tiles holding geometry are counted or dropped. An entry whose value is - ``None`` is the source's word that the tile is absent — a settled answer costing a - dict slot rather than a megabyte, so it stays; dropping it would only buy a - pointless re-request on the next repaint. A tile we got no answer about isn't here - at all: it waits out its cooldown in :attr:`_unanswered` and is asked for again. + The history a pan back needs lives in the source, whose memory is budgeted in + bytes against the machine's RAM (see :meth:`~meshterm.services.basemap. + BasemapSource.resident`) — so a tile held here as well would be held *outside* that + budget. That is how the map used to freeze on the PicoCalc: eight-odd tiles of + history here, at up to 4.6 MB each, on top of the source's own, pushed a 100 MB + device into swapping to its SD card. + + Only tiles holding geometry are dropped. An entry whose value is ``None`` is the + source's word that the tile is absent — a settled answer costing a dict slot rather + than a megabyte, so it stays; dropping it would only buy a pointless re-request on + the next repaint. A tile we got no answer about isn't here at all: it waits out its + cooldown in :attr:`_unanswered` and is asked for again. Args: - in_view: How many tiles the current viewport needs, which sets the budget. + wanted: The tiles the current viewport needs. """ - budget = max(_MIN_TILE_CACHE, in_view * _TILE_CACHE_SCREENS) - loaded = sum(1 for layers in self._tiles.values() if layers) - if loaded <= budget: - return - # Oldest first; everything on screen was just moved to the end, so it is safe. - for key in list(self._tiles): - if loaded <= budget: - break - if self._tiles[key]: - del self._tiles[key] - loaded -= 1 + keep = set(wanted) + for key in [k for k, layers in self._tiles.items() if layers and k not in keep]: + del self._tiles[key] async def _load(self, t: tuple[int, int, int]) -> None: """Fetch+decode one tile off the event loop, then repaint. @@ -936,11 +946,23 @@ class MapScreen(Screen): That distinction is only visible on a link that actually drops. On the PicoCalc it is the difference between a map and a map with holes in it (JP, 2026-08-18: tiles black at the two highest zooms, where one z14 tile is the whole screen). + + A load waits its turn (:data:`_TILE_LOADS`), and a tile the view has left by the + time its turn comes is **dropped unloaded**. A fast pan or a run of zoom steps asks + for every view it passes through, and loading all of them — each a decode of a + second or more, the zoomed-out ones the heaviest — was the backlog that kept the map + behind the keys long after they stopped. Dropped is not written off: nothing is + recorded, so the next paint that wants the tile asks for it like any other. """ - try: - layers = await asyncio.to_thread(self._source.load_tile, *t) - except Exception: # noqa: BLE001 - a failed tile is just an absent one - layers = None + async with self._tile_gate: + vp = self._viewport + if vp is not None and t not in vp.tiles(self._max_tile_zoom): + self._pending.discard(t) + return + try: + layers = await asyncio.to_thread(self._source.load_tile, *t) + except Exception: # noqa: BLE001 - a failed tile is just an absent one + layers = None self._pending.discard(t) if layers is not None or self._source.answered_empty(*t): self._tiles[t] = layers diff --git a/meshterm/ui/mapcanvas.py b/meshterm/ui/mapcanvas.py index 0fdca13..118e512 100644 --- a/meshterm/ui/mapcanvas.py +++ b/meshterm/ui/mapcanvas.py @@ -15,6 +15,7 @@ truecolour ANSI lines, which is exactly what the TUI frame consumes. from __future__ import annotations +import math import unicodedata from dataclasses import dataclass from itertools import pairwise @@ -227,26 +228,36 @@ class MapCanvas: cells. ``2`` lights a quarter of the dots, enough to read as tone while leaving room for a road to stay a legible line through it. ``1``, the default, is the ordinary solid fill. + + Each edge is filed under the scanlines it actually crosses, rather than every + scanline testing every edge. The two give the same dots, but the second costs rows + times edges, and a zoomed-out view is exactly where a polygon is a coastline or a + province with tens of thousands of edges: on the PicoCalc a z7 frame spent 6.8 of + its 7.8 s here, almost all of it asking edges about rows they come nowhere near. """ - edges: list[tuple[float, float, float, float]] = [] - ys: list[float] = [] + last_row = self.dot_h - 1 + crossings: dict[int, list[float]] = {} for ring in rings: for (x0, y0), (x1, y1) in pairwise(ring): - if y0 != y1: - edges.append((x0, y0, x1, y1)) - ys.extend((y0, y1)) - if not edges: - return - y_start = max(0, int(min(ys))) - y_end = min(self.dot_h - 1, int(max(ys))) - if stipple > 1: - y_start += -y_start % stipple - for y in range(y_start, y_end + 1, stipple): - yc = y + 0.5 - xs: list[float] = [] - for x0, y0, x1, y1 in edges: - if (y0 <= yc < y1) or (y1 <= yc < y0): - xs.append(x0 + (yc - y0) * (x1 - x0) / (y1 - y0)) + if y0 == y1: + continue + # The scanline through a row's middle, yc = y + 0.5, crosses this edge when + # it lies in [low, high) — half-open, so a vertex shared by two edges is + # counted once. Solved for the row, clipped to the canvas. + low, high = (y0, y1) if y0 < y1 else (y1, y0) + first = max(0, math.ceil(low - 0.5)) + last = min(last_row, math.ceil(high - 0.5) - 1) + if stipple > 1: + first += -first % stipple + if first > last: + continue + dx, dy = x1 - x0, y1 - y0 + for y in range(first, last + 1, stipple): + row = crossings.get(y) + if row is None: + row = crossings[y] = [] + row.append(x0 + (y + 0.5 - y0) * dx / dy) + for y, xs in crossings.items(): xs.sort() for i in range(0, len(xs) - 1, 2): x_from = max(0, int(round(xs[i]))) diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index 148d321..18c9055 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -491,6 +491,12 @@ class _StubSource: def answered_empty(self, z: int, x: int, y: int) -> bool: return False # offline is silence, never the source saying "nothing there" + def resident(self, z: int, x: int, y: int): + return None # nothing is ever held in RAM + + def warm(self, z: int, x: int, y: int) -> None: + return None + def _picker(markers=None, initial=None): from meshterm.ui.map_screen import LocationPickScreen diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 329aaaa..1f21242 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -399,6 +399,12 @@ class _StubTileSource: def answered_empty(self, z: int, x: int, y: int) -> bool: # noqa: ANN001 return False # offline is silence, never the source saying "nothing there" + def resident(self, z: int, x: int, y: int): # noqa: ANN001 + return None # nothing is ever held in RAM + + def warm(self, z: int, x: int, y: int) -> None: # noqa: ANN001 + return None + def _map_body_rows(rows: int) -> int: """The body height the map would be handed in the frame these specimens compose into. diff --git a/tests/test_map.py b/tests/test_map.py index d34beba..e7d2bb5 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -190,6 +190,100 @@ def test_offscreen_buildings_do_not_change_the_frame() -> None: assert frame([here]) == frame([here, far]) +def test_offscreen_rings_do_not_change_the_frame() -> None: + """Every layer skips the rings its view cannot reach, and the skip is just as invisible. + + A zoomed-out view sees a sliver of tiles whose coastlines and land cover run to tens of + thousands of points, and projecting every one of them was half of a z7 frame on the + PicoCalc. A hole far off screen in a lake that covers the view, and a road and a river + that never come near it, must render byte-identically to their absence — the hole most + of all, since it shares an even-odd fill with the lake around it. + """ + from meshterm.core.mvt import Feature + from meshterm.ui.map_render import render_map + + # At z16 a z14 tile is 1024 dots across, four tile units a dot: the 106x104-dot view + # spans 424x416 units around its centre, so 400 units out is beyond it on every side. + vp = Viewport(45.4995, -73.5690, 16, 53 * 2, 26 * 4) + cx, cy = _tile_local_of_view_centre(vp, 14, 4843, 5861) + + def square(x0, y0, x1, y1): + return [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + + lake = square(cx - 600, cy - 600, cx + 600, cy + 600) + island = square(cx - 20, cy - 20, cx + 20, cy + 20) # a hole the view does show + far_hole = square(cx - 590, cy - 590, cx - 500, cy - 500) + far_road = [(cx - 590, cy + 300), (cx - 400, cy + 590)] + far_river = [(cx + 450, cy - 590), (cx + 590, cy + 590)] + + def frame(water_rings, roads, rivers): + layers = [ + Layer("water", 4096, [Feature(GEOM_POLYGON, water_rings, {})]), + Layer("transportation", 4096, [Feature(GEOM_LINE, roads, {"class": "primary"})]), + Layer("waterway", 4096, [Feature(GEOM_LINE, rivers, {"class": "river"})]), + ] + return render_map(vp, {(14, 4843, 5861): layers}, []) + + near_road = [(cx - 300, cy), (cx + 300, cy)] + near_river = [(cx, cy - 300), (cx, cy + 300)] + bare = frame([lake, island], [near_road], [near_river]) + assert _plain(bare).strip(), "the lake, the road and the river should all draw" + assert bare == frame([lake, island, far_hole], [near_road, far_road], [near_river, far_river]) + + +def test_polygon_fill_lights_exactly_the_dots_a_scan_of_every_edge_would() -> None: + """Filing each edge under the rows it crosses is a speed-up, so it must not move a dot. + + The reference is the fill as it was: every scanline asking every edge whether it is + crossed. That costs rows times edges, which a coastline at z7 made 6.8 s of a frame on + the PicoCalc; the answer it gave is the specification. + """ + import random + from itertools import pairwise + + def reference(canvas: MapCanvas, rings, stipple: int) -> None: + edges = [ + (x0, y0, x1, y1) + for ring in rings + for (x0, y0), (x1, y1) in pairwise(ring) + if y0 != y1 + ] + for y in range(0, canvas.dot_h, stipple): + yc = y + 0.5 + xs = sorted( + x0 + (yc - y0) * (x1 - x0) / (y1 - y0) + for x0, y0, x1, y1 in edges + if y0 <= yc < y1 or y1 <= yc < y0 + ) + for i in range(0, len(xs) - 1, 2): + lo = max(0, int(round(xs[i]))) + hi = min(canvas.dot_w - 1, int(round(xs[i + 1]))) + if stipple > 1: + lo += -lo % stipple + for x in range(lo, hi + 1, stipple): + canvas.plot(x, y, (1, 2, 3), 5) + + def coordinate(rnd: random.Random) -> float: + # Mostly anywhere, including well off the canvas; sometimes exactly on a row's + # middle or a dot's edge, which is where an off-by-one would show. + if rnd.random() < 0.5: + return rnd.uniform(-30.0, 110.0) + return rnd.randint(-5, 90) + rnd.choice((0.0, 0.5, -0.5)) + + rnd = random.Random(7) + for _ in range(500): + w, h = rnd.randint(1, 40), rnd.randint(1, 20) + rings = [] + for _ in range(rnd.randint(1, 3)): + ring = [(coordinate(rnd), coordinate(rnd)) for _ in range(rnd.randint(2, 10))] + rings.append(ring + ring[:1] if rnd.random() < 0.7 else ring) + stipple = rnd.choice((1, 1, 2, 3)) + fast, slow = MapCanvas(w, h), MapCanvas(w, h) + fast.fill_polygon(rings, (1, 2, 3), 5, stipple=stipple) + reference(slow, rings, stipple) + assert fast._bits == slow._bits, (w, h, stipple, rings) + + # -- projection / viewport ---------------------------------------------------- @@ -1162,6 +1256,91 @@ def test_basemap_source_prunes_the_decoded_cache_to_its_budget(tmp_path: Path) - assert left == ["3", "4", "5"], "the oldest sidecars go first" +def test_resident_bytes_tracks_what_a_decoded_tile_really_holds() -> None: + """The memory budget is only as good as its scale, so the scale is checked against RAM. + + A count of tiles was the old measure, and a tile weighs anywhere from 1 to 4.6 MB on the + PicoCalc; the estimate has to land near what ``tracemalloc`` sees, on this build's word + size, or the budget it feeds is a guess. + """ + import gc + import tracemalloc + + from meshterm.core.mvt import dumps_layers, loads_layers, resident_bytes + from meshterm.ui.map_render import DRAWN_LAYERS + + blob = dumps_layers(decode_tile(_FIXTURE.read_bytes(), layers=DRAWN_LAYERS)) + gc.collect() + tracemalloc.start() + try: + layers = loads_layers(blob) + held, _ = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert layers + assert 0.8 <= resident_bytes(layers) / held <= 1.4 + + +def test_basemap_source_holds_decoded_tiles_to_a_byte_budget(tmp_path: Path) -> None: + """Decoded tiles in RAM are budgeted in bytes: the oldest go first, the newest always stays.""" + from meshterm.core.mvt import resident_bytes + from meshterm.services.basemap import BasemapSource + + tile = decode_tile(_FIXTURE.read_bytes()) + one = resident_bytes(tile) + src = BasemapSource(tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", memo_bytes=one * 5 // 2) + for i in range(5): + src._remember((14, i, 0), tile) + + assert [key[1] for key in src._memo] == [3, 4], "the two newest fit; the rest went oldest first" + assert src._memo_bytes == 2 * one + assert src.resident(14, 4, 0) is tile + assert src.resident(14, 0, 0) is None + + # A tile heavier than the whole budget is still kept, alone: a view asked for it. + tight = BasemapSource(tmp_path / "cache", tilejson_url="http://127.0.0.1:1/none", memo_bytes=1) + tight._remember((14, 0, 0), tile) + tight._remember((14, 1, 0), tile) + assert list(tight._memo) == [(14, 1, 0)] + + +def test_basemap_memory_budget_scales_with_the_machine(monkeypatch: pytest.MonkeyPatch) -> None: + """An eighth of the RAM, within bounds — the PicoCalc's 100 MB gets ~13 MB of tiles.""" + from meshterm.services import basemap + + def machine(pages: int): + sizes = {"SC_PAGE_SIZE": 4096, "SC_PHYS_PAGES": pages} + monkeypatch.setattr(basemap.os, "sysconf", sizes.__getitem__, raising=False) + + machine(26142) # the PicoCalc's 104568 kB + assert basemap._memo_budget() == 4096 * 26142 // basemap._MEMO_SHARE + machine(4 * 1024 * 1024) # a 16 GB desktop + assert basemap._memo_budget() == basemap._MEMO_CEILING + machine(1024) # 4 MB: too small to hold a view at an eighth + assert basemap._memo_budget() == basemap._MEMO_FLOOR + monkeypatch.delattr(basemap.os, "sysconf", raising=False) # Windows + assert basemap._memo_budget() == basemap._MEMO_CEILING + + +def test_basemap_warm_writes_a_tile_down_and_holds_nothing(tmp_path: Path) -> None: + """A prefetch buys the decode, on disk, and leaves the RAM to where the reader has been.""" + cache = tmp_path / "cache" + tile = cache / "tiles" / "14" / "4843" / "5861.pbf" + tile.parent.mkdir(parents=True) + tile.write_bytes(_FIXTURE.read_bytes()) + + src = _offline_source(cache) + src.warm(14, 4843, 5861) + assert (cache / "decoded" / "14" / "4843" / "5861.bin").exists() + assert src.resident(14, 4843, 5861) is None, "a guess was held in RAM" + + # A tile already written down costs a second guess nothing: not even a read. + reads: list[Path] = [] + src._read_cached = lambda path: reads.append(path) # type: ignore[method-assign] + src.warm(14, 4843, 5861) + assert reads == [] + + def test_basemap_source_remembers_a_blank_tile_for_the_session(tmp_path: Path) -> None: """An answered-empty tile isn't re-requested this session (but isn't written down).""" from meshterm.services import basemap as basemap_mod @@ -1377,6 +1556,12 @@ class _StubSource: def answered_empty(self, z: int, x: int, y: int) -> bool: return False # offline is silence, never the source saying "nothing there" + def resident(self, z: int, x: int, y: int): + return None # nothing is ever held in RAM + + def warm(self, z: int, x: int, y: int) -> None: + return None + def _loaded_tile() -> list[Layer]: """A stand-in for a decoded tile: truthy, which is all the budget cares about.""" @@ -1395,26 +1580,21 @@ def _map_screen_with_tiles(count: int) -> MapScreen: return screen -def _budget(screen) -> int: # noqa: ANN001 - the screen's own arithmetic, mirrored - from meshterm.ui.map_screen import _MIN_TILE_CACHE, _TILE_CACHE_SCREENS +def test_map_holds_only_the_tiles_its_view_shows() -> None: + """Panning far leaves no decoded ground behind on the screen. - in_view = len(screen._viewport.tiles(14)) - return max(_MIN_TILE_CACHE, in_view * _TILE_CACHE_SCREENS) - - -def test_map_tile_cache_stays_bounded_while_panning() -> None: - """Panning far doesn't accumulate decoded tiles without limit. - - One decoded tile is ~0.9 MB on the PicoCalc, which has ~100 MB in total, so an - unbounded cache turns a long pan into an out-of-memory kill. + A decoded tile is 1-4.6 MB on the PicoCalc, which has ~100 MB in total. The history a + pan back needs is the source's, budgeted in bytes; a tile the screen kept as well would + sit outside that budget, which is how a fast map once swapped the device to a standstill. """ screen = _map_screen_with_tiles(200) for _ in range(12): screen.handle("right") screen.render_body(80) - held = sum(1 for layers in screen._tiles.values() if layers) - assert held <= _budget(screen), f"{held} decoded tiles held, over budget" + visible = set(screen._viewport.tiles(14)) + held = {k for k, layers in screen._tiles.items() if layers} + assert held <= visible, f"{len(held - visible)} decoded tiles held off screen" def test_map_tile_cache_never_drops_what_is_on_screen() -> None: @@ -2076,6 +2256,9 @@ class _CountingSource(_StubSource): self.asked.append((z, x, y)) return _loaded_tile() + def warm(self, z: int, x: int, y: int) -> None: + self.asked.append((z, x, y)) # a guess is an ask too, just one that holds nothing + def _settled_map(source, zoom: int = 13): """A map screen with its own view drawn and every visible tile in hand.""" @@ -2264,6 +2447,50 @@ def test_map_keeps_no_speculated_tile_of_its_own() -> None: assert screen._speculated, "nothing was speculated at all" +def test_map_takes_a_tile_already_in_memory_without_a_round_trip() -> None: + """Ground panned back onto is in the very first raster, not a thread hop later.""" + + class _HeldSource(_CountingSource): + def resident(self, z: int, x: int, y: int): + return _loaded_tile() + + source = _HeldSource() + + async def drive(): + screen = _settled_map(source) + screen.render_body(80) + return screen + + screen = asyncio.run(drive()) + visible = screen._viewport.tiles(14) + assert all(screen._tiles.get(t) for t in visible), "a held tile was not taken" + assert not screen._pending and not source.asked, "a held tile was sent to a thread" + + +def test_map_drops_a_tile_the_view_left_before_its_turn() -> None: + """A fast pan asks for every view it passes; only where it stopped is worth loading. + + Loading the rest — a decode of a second or more each, on the PicoCalc — was the backlog + that kept the map behind the keys long after they stopped. + """ + source = _CountingSource() + + async def drive(): + screen = _settled_map(source) + screen.render_body(80) # asks for the opening view's tiles + opening = set(screen._pending) + for _ in range(8): # gone, before any load has had its turn + screen.handle("right") + await asyncio.sleep(0.05) + return screen, opening + + screen, opening = asyncio.run(drive()) + left_behind = opening - set(screen._viewport.tiles(14)) + assert left_behind, "the pan never left the opening view" + assert not left_behind & set(source.asked), "a tile the view had left was loaded anyway" + assert not left_behind & screen._pending, "a dropped tile is still counted as on its way" + + # --- the coarse first pass -----------------------------------------------------------