A map moved faster than it can draw no longer freezes the PicoCalc
Panning or zooming the map quickly on the PicoCalc could stop the whole app
for many seconds. A watchdog run on the device found two causes behind it,
plus the port walk fixed in the previous commit.
Memory. A decoded tile holds 1 to 4.6 MB of RAM on the device (65 bytes a
point), not the 0.9 MB the code assumed. The source kept up to 24 of them,
counted as tiles, and the map screen kept 8 or more of its own on top, so a
few pans and a zoom out filled a 104 MB device. Swap is on the SD card, so
the process then stopped while the kernel read its code back in: 16,000
major page faults and a 15 s freeze in one run. Now the source holds decoded
tiles to a byte budget, one eighth of the machine's RAM (12.8 MB on the
PicoCalc, at most 128 MB on a desktop), weighed by mvt.resident_bytes. That
estimate was fitted against tracemalloc on the device's 157 cached tiles: 1%
under to 9% over there, and 6% under to 21% over on a 64-bit desktop. The
screen keeps only the tiles in view, and takes any tile already in RAM
straight from the source on the paint (BasemapSource.resident), with no trip
through a thread. The prefetcher warms the disk cache (BasemapSource.warm)
instead of filling the RAM with guesses, and passes over any tile already
written down. Tile loads take turns, two at a time, and a tile the view has
left before its turn comes is dropped, not loaded.
CPU. fill_polygon asked every edge about every scanline, which at z7 means
coastlines with tens of thousands of edges: 6.8 s of a 7.6 s frame. Each
edge is now filed under only the rows it crosses, and draws the same dots
(checked against the old fill on 3,000 random polygons, and in a test).
_draw_tile also tests each ring's bounds before projecting it, and skips a
ring wholly outside the view. That changes no dot: 1,500 frames from the
device's tiles match rendering with no culling at all. The one frame that
differed from HEAD was HEAD's own building cull, which dropped a footprint
whose fill rounds onto the canvas edge.
Measured on the PicoCalc with the profile-picocalc map_burst tour (keys sent
at 10 a second, from a pinned z10 view, real radio and database). The new
code ran first, so the baseline had the warmer tile cache:
pans, catch-up after a burst HEAD 1.0-1.8 s now 0.4-0.6 s
longest silence, whole tour HEAD 11.6 s now 0.6 s
whole-process freezes HEAD 8.0, 3.1, 2.1, 11.6 s
now one, 1.6 s (idle pages swapped back in)
swap at its peak HEAD 25 MB now 7 MB
warm raster, z7 / z8 / z9 HEAD 7.6 / 1.9 / 1.8 s
now 0.57 / 0.29 / 0.81 s
warm raster, z10 / z13 HEAD 0.49 / 0.56 s now 0.26 / 0.26 s
Still open: z9 spends 0.23 s drawing lines; a prefetch decode of a heavy z7
tile is still 1.3 to 1.7 s on a worker thread; and pages swapped out while
the map sits idle can still cost a second or two to come back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3095e3b236
commit
db6370407a
8 files changed
+547
-132
No files matched your search
@@ -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
|
||||
+100
-26
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
+46
-27
@@ -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:
|
||||
|
||||
+71
-49
@@ -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
|
||||
|
||||
+28
-17
@@ -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])))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+240
-13
@@ -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 -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user