Profiling on the Lyra found vector-tile decode dominating everything else in the app by about two orders of magnitude: a median cached tile took 1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a whole viewport. Decoded tiles are memoised for the session, so this is a first-pan cost -- but it runs on a GIL-bound worker thread, so it starves the render loop for those seconds rather than running beside it. Roughly 40% of that work was thrown away. A planet tile ships building, housenumber, poi and mountain_peak; _draw_tile looks up ten layer names and none of them is those. So decode_tile now takes the set of layers the caller actually wants, reads each layer's identity with a cheap header walk, and decodes the features only for the ones asked for. Unwanted layers are still returned, named and empty -- which matters, because the tile source decides whether a cache entry is real by whether the bytes parsed into layers, and a tile whose every layer we skip must not read as blank and get its perfectly good cache entry pruned and re-downloaded every session. That test moves off "has features" and onto "parsed into layers", which is sound because the other two ways a tile isn't real -- empty and corrupt -- decode to nothing and raise respectively. Only the decode narrows; whole tiles are still cached, so drawing more later costs a re-decode and never a re-download. Measured on the device across the 46 real cached tiles: 183 s -> 51 s, 3.6x, with the drawn layers byte-identical on every one. The median tile is 1259 -> 353 ms, the largest 3545 -> 759 ms. _Reader.skip stopped slicing out the bytes it steps over while it was here, which is most of what makes the header walk cheap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Fetch and cache OpenStreetMap vector tiles for the terminal map.
|
|
|
|
This is the only networked part of the map. It resolves a tile source's (versioned) tile-URL
|
|
template from its TileJSON, fetches ``.pbf`` vector tiles over HTTPS, and caches them on disk
|
|
so panning back over ground you've seen is instant and later sessions work offline. Decoding
|
|
is delegated to the pure :mod:`meshterm.core.mvt`, and narrowed to the layers the renderer
|
|
actually draws (``layers``) — a planet tile carries buildings, house numbers and POIs the
|
|
terminal map has no pixels for, and they are the bulk of its decode cost. Only the decode
|
|
narrows: whole tiles are still cached, so drawing more later costs a re-decode, never a
|
|
re-download.
|
|
|
|
The default source is **OpenFreeMap** (openfreemap.org) — full-planet OpenStreetMap vector
|
|
tiles, free and requiring no API key. Everything here is best-effort: with no network and no
|
|
cached tiles the loader simply returns ``None`` and the map falls back to plotting nodes on a
|
|
blank grid, never raising into the UI.
|
|
|
|
**Only tiles that decode to real geometry are ever written to disk.** A cache is a memory,
|
|
and the one thing it must not remember is a lie: on a flaky link (the PicoCalc's Wi-Fi, most
|
|
of all) a timeout, a reset, or a body cut short is *silence*, not an empty tile, and storing
|
|
it leaves a blank square on the map for every future session. So a fetch is only cached once
|
|
its bytes have decoded to at least one feature; anything else is either dropped on the floor
|
|
(no answer — try again next pan) or remembered in memory for this session alone (the source
|
|
answered, and there is genuinely nothing there). Cache entries written by earlier builds that
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Container, NamedTuple, Optional
|
|
|
|
from ..core.mvt import Layer, decode_tile
|
|
|
|
#: OpenFreeMap planet TileJSON — its ``tiles`` array holds the current versioned template.
|
|
DEFAULT_TILEJSON_URL = "https://tiles.openfreemap.org/planet"
|
|
|
|
#: Sent on every request; identifies the client per common tile-usage etiquette.
|
|
_USER_AGENT = "MeshTerm/0.1 (+https://github.com/; mesh node map)"
|
|
|
|
#: Fallback max tile zoom if the TileJSON doesn't declare one (OpenFreeMap serves 14).
|
|
_DEFAULT_MAX_ZOOM = 14
|
|
|
|
#: HTTP statuses that are the source *answering* "there is no such resource". Everything
|
|
#: else — 429, 5xx, and every transport error — is the absence of an answer.
|
|
_ABSENT_STATUSES = frozenset({404, 410})
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
|
|
class _Response(NamedTuple):
|
|
"""One HTTP GET's outcome: whether the server answered, and what it said.
|
|
|
|
The distinction is the whole point. A definitive answer can be acted on — bytes to
|
|
decode, or a 404 meaning there is no tile at those coordinates. Offline, DNS failure,
|
|
timeout, connection reset, throttling, a server fault, a body short of its declared
|
|
length: none of those tell us anything about the tile, and must never be mistaken for
|
|
the source saying it is empty.
|
|
|
|
Attributes:
|
|
answered: Whether the server gave a definitive answer.
|
|
body: The body it gave; empty when the answer was "no such resource".
|
|
"""
|
|
|
|
answered: bool
|
|
body: bytes
|
|
|
|
|
|
class BasemapSource:
|
|
"""A cached OpenStreetMap vector-tile source.
|
|
|
|
Attributes:
|
|
cache_dir: Directory under which tiles and the resolved TileJSON are cached.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
cache_dir: Path,
|
|
*,
|
|
tilejson_url: str = DEFAULT_TILEJSON_URL,
|
|
timeout: float = 12.0,
|
|
layers: Optional[Container[str]] = None,
|
|
) -> None:
|
|
"""Open a tile source backed by an on-disk cache.
|
|
|
|
Args:
|
|
cache_dir: Where fetched tiles and TileJSON metadata are stored.
|
|
tilejson_url: URL of the source's TileJSON document.
|
|
timeout: Per-request network timeout in seconds.
|
|
layers: Restrict decoding to these layer names (the renderer passes
|
|
:data:`meshterm.ui.map_render.DRAWN_LAYERS`). Only the *decode* narrows —
|
|
the cache still stores whole tiles — so this is a pure CPU saving that a
|
|
later renderer can widen without re-fetching anything.
|
|
"""
|
|
self.cache_dir = cache_dir
|
|
self._tilejson_url = tilejson_url
|
|
self._timeout = timeout
|
|
self._layers = layers
|
|
self._template: Optional[str] = None
|
|
self._max_zoom: Optional[int] = None
|
|
self._resolved = False # whether we've tried (success or offline) this session
|
|
# Tiles the source answered "nothing here" for this session. Held in memory rather
|
|
# 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()
|
|
|
|
# -- metadata ---------------------------------------------------------------
|
|
|
|
def _tilejson_path(self) -> Path:
|
|
return self.cache_dir / "tilejson.json"
|
|
|
|
def _resolve(self) -> None:
|
|
"""Resolve and cache the tile-URL template and max zoom (best-effort, once)."""
|
|
if self._resolved:
|
|
return
|
|
self._resolved = True
|
|
# Prefer a freshly fetched TileJSON (the template is versioned and rotates), but fall
|
|
# back to a previously cached copy so a session started offline can still use disk
|
|
# tiles and even re-fetch if the template is still valid.
|
|
resp = self._http_get(self._tilejson_url)
|
|
data = resp.body if resp.answered and resp.body else None
|
|
if data is not None:
|
|
try:
|
|
self._tilejson_path().parent.mkdir(parents=True, exist_ok=True)
|
|
self._tilejson_path().write_bytes(data)
|
|
except OSError: # pragma: no cover - cache write failure is non-fatal
|
|
pass
|
|
if data is None and self._tilejson_path().exists():
|
|
try:
|
|
data = self._tilejson_path().read_bytes()
|
|
except OSError: # pragma: no cover
|
|
data = None
|
|
if data is None:
|
|
return
|
|
try:
|
|
doc = json.loads(data)
|
|
tiles = doc.get("tiles") or []
|
|
if tiles:
|
|
self._template = str(tiles[0])
|
|
self._max_zoom = int(doc.get("maxzoom", _DEFAULT_MAX_ZOOM))
|
|
except (ValueError, TypeError): # pragma: no cover - malformed TileJSON
|
|
pass
|
|
|
|
@property
|
|
def max_zoom(self) -> int:
|
|
"""The highest zoom the source serves (resolved lazily; sensible default offline)."""
|
|
self._resolve()
|
|
return self._max_zoom if self._max_zoom is not None else _DEFAULT_MAX_ZOOM
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Whether a tile-URL template is known (network reachable, or one was cached)."""
|
|
self._resolve()
|
|
return self._template is not None
|
|
|
|
# -- tiles ------------------------------------------------------------------
|
|
|
|
def _tile_path(self, z: int, x: int, y: int) -> Path:
|
|
return self.cache_dir / "tiles" / str(z) / str(x) / f"{y}.pbf"
|
|
|
|
def load_tile(self, z: int, x: int, y: int) -> Optional[list[Layer]]:
|
|
"""Return the decoded layers for a tile, from cache or the network.
|
|
|
|
The cache only ever holds tiles that decoded to real geometry. A tile the source
|
|
never answered for is left uncached, so the next pan over it asks again instead of
|
|
drawing a permanent blank; a cached file that no longer decodes is pruned for the
|
|
same reason.
|
|
|
|
Args:
|
|
z: Tile zoom.
|
|
x: Tile x index.
|
|
y: Tile y index.
|
|
|
|
Returns:
|
|
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:
|
|
return None
|
|
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:
|
|
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.
|
|
# Either way it is a blank square for ever unless we drop it and re-ask.
|
|
_log.debug("dropping unusable cached tile %s/%s/%s", z, x, y)
|
|
self._discard_cached(path)
|
|
raw = self._fetch_tile(z, x, y)
|
|
if raw is None:
|
|
return None # no answer — nothing learned, so nothing is written down
|
|
layers = self._decode(raw, key)
|
|
if layers is None:
|
|
self._blank.add(key) # the source answered: there is genuinely nothing here
|
|
return None
|
|
self._write_cached(path, raw)
|
|
return layers
|
|
|
|
def _decode(self, raw: bytes, key: tuple[int, int, int]) -> Optional[list[Layer]]:
|
|
"""Decode a tile's bytes, or ``None`` if they aren't a tile at all.
|
|
|
|
The question this answers is "are these real tile bytes?", and the answer decides
|
|
whether a cache entry is kept or pruned — so it must not be confused with "is
|
|
there anything here I feel like drawing". Under ``layers`` most of a tile's
|
|
content is deliberately left undecoded, and a tile whose every layer we skip
|
|
would otherwise read as blank and get its (perfectly good) cache entry deleted
|
|
and re-downloaded every session.
|
|
|
|
So the test is whether the bytes parsed into any layer at all. That is sound
|
|
because the three ways a tile is *not* real are all distinguishable without
|
|
looking at features: empty bytes decode to no layers, and truncated or junk bytes
|
|
raise out of the parser rather than yielding a well-formed featureless layer.
|
|
|
|
Args:
|
|
raw: The tile's raw ``.pbf`` bytes.
|
|
key: The ``(z, x, y)`` the bytes claim to be, for the log line.
|
|
|
|
Returns:
|
|
The decoded layers, or ``None`` for empty or corrupt bytes.
|
|
"""
|
|
try:
|
|
layers = decode_tile(raw, layers=self._layers)
|
|
except Exception as exc: # noqa: BLE001 - a corrupt tile must not crash the map
|
|
_log.debug("failed to decode tile %s/%s/%s: %s", *key, exc)
|
|
return None
|
|
return layers or None
|
|
|
|
def _fetch_tile(self, z: int, x: int, y: int) -> Optional[bytes]:
|
|
"""Fetch a tile's raw bytes from the network.
|
|
|
|
Args:
|
|
z: Tile zoom.
|
|
x: Tile x index.
|
|
y: Tile y index.
|
|
|
|
Returns:
|
|
The body the source gave — possibly empty, meaning "no tile at those
|
|
coordinates" — or ``None`` if it never answered (offline, timed out,
|
|
throttled, faulted, or cut short).
|
|
"""
|
|
self._resolve()
|
|
if self._template is None:
|
|
return None
|
|
url = (
|
|
self._template.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
|
|
)
|
|
resp = self._http_get(url)
|
|
return resp.body if resp.answered else None
|
|
|
|
@staticmethod
|
|
def _read_cached(path: Path) -> Optional[bytes]:
|
|
try:
|
|
return path.read_bytes() if path.exists() else None
|
|
except OSError: # pragma: no cover
|
|
return None
|
|
|
|
@staticmethod
|
|
def _write_cached(path: Path, raw: bytes) -> None:
|
|
"""Write a tile to the cache atomically, so a half-written one is never read back."""
|
|
# Unique per writer: the map screen and a mini-map share one source, and both may
|
|
# be fetching the same tile in their own worker threads.
|
|
tmp = path.with_name(f"{path.name}.{os.getpid()}-{threading.get_ident()}.part")
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp.write_bytes(raw)
|
|
os.replace(tmp, path)
|
|
except OSError: # pragma: no cover - cache write failure is non-fatal
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def _discard_cached(path: Path) -> None:
|
|
"""Drop a cache entry that proved unusable, so the tile can be fetched afresh."""
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
except OSError: # pragma: no cover - a cache we can't prune is still readable
|
|
pass
|
|
|
|
def _http_get(self, url: str) -> _Response:
|
|
"""GET a URL, telling a definitive answer apart from no answer at all.
|
|
|
|
Args:
|
|
url: The absolute URL to fetch.
|
|
|
|
Returns:
|
|
The outcome — see :class:`_Response`.
|
|
"""
|
|
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
body = resp.read()
|
|
declared = (resp.headers.get("Content-Length") or "").strip()
|
|
# A flaky link can end a read early without raising. A body short of its
|
|
# declared length is a truncation, not a tile — and not an empty one.
|
|
if declared.isdigit() and len(body) != int(declared):
|
|
_log.debug("short read for %s: %d of %s bytes", url, len(body), declared)
|
|
return _Response(False, b"")
|
|
return _Response(True, body)
|
|
except urllib.error.HTTPError as exc:
|
|
answered = exc.code in _ABSENT_STATUSES
|
|
if not answered:
|
|
_log.debug("fetch failed for %s: HTTP %s", url, exc.code)
|
|
return _Response(answered, b"")
|
|
except Exception as exc: # noqa: BLE001 - offline / timeout / reset are "no answer"
|
|
_log.debug("fetch failed for %s: %s", url, exc)
|
|
return _Response(False, b"")
|