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:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-04 17:15:19 -04:00
1 parent 3095e3b236
commit db6370407a
8 files changed
+547 -132

No files matched your search

+50
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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])))
+6
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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 -----------------------------------------------------------