lidar-maps : navigation en cache seule et pyramide entretenue en tâche de fond
Sur une petite machine (Raspberry Pi), LIDAR_TILE_CACHE_ONLY sert les tuiles depuis le cache uniquement (tuile absente = transparente non mémorisable, X-Tile-Pending) et LIDAR_TILE_BACKGROUND fait surveiller les dalles par un sondeur : chaque dalle nouvelle ou régénérée par le worker remet sa pyramide en file, rendue à basse priorité et au ralenti. Scan et état pilotables via /api/map/background, pré-calcul exhaustif toujours via /api/map/warm.
This commit is contained in:
45
docs/MAPS.md
45
docs/MAPS.md
@ -23,6 +23,7 @@ docker compose -f docker-compose.maps.yml logs -f maps
|
||||
| Variante haute densité | `/tiles/{couche}/{z}/{x}/{y}@2x.webp` — 512 px (interface interne) |
|
||||
| Zooms | 5 → 19 natif (0,2 m/px ≈ z19 en France) ; au-delà, sur-zoom côté client |
|
||||
| Hors emprise | tuile entièrement transparente (superposable), en-tête `X-Tile-Empty: 1` |
|
||||
| En attente (cache seule) | tuile transparente, en-têtes `X-Tile-Empty: 1` + `X-Tile-Pending: 1`, jamais mémorisée par le navigateur |
|
||||
| CORS | `Access-Control-Allow-Origin: *` sur `/tiles/*` |
|
||||
| Attribution | `LIDAR_ATTRIBUTION`, défaut « LiDAR HD © IGN — Licence Ouverte 2.0 » |
|
||||
|
||||
@ -164,8 +165,7 @@ Le service est borné à chaque étage, rien n'est illimité :
|
||||
|
||||
Les requêtes en excès attendent le sémaphore **avant** tout décodage : elles ne
|
||||
consomment ni CPU ni mémoire. Un navigateur en HTTP/1.1 n'ouvre de toute façon
|
||||
que 6 connexions par origine, toutes couches confondues ; derrière un proxy
|
||||
HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge.
|
||||
que 6 connexions par origine, toutes couches confondues ; derrière un proxy HTTP/2 ce plafond disparaît et seuls ces réglages tiennent la charge.
|
||||
|
||||
Le pré-chauffage (`/api/map/warm`) est séquentiel : il ne peut pas saturer la
|
||||
machine, seulement prendre du temps.
|
||||
@ -173,6 +173,47 @@ machine, seulement prendre du temps.
|
||||
Sur un Raspberry Pi 2 Go, `LIDAR_TILE_WORKERS=1` et
|
||||
`LIDAR_TILE_SOURCE_CACHE_MB=64` restent confortables.
|
||||
|
||||
## Cache seule + maintenance de fond (petit Raspberry Pi)
|
||||
|
||||
Sur une machine qui ne doit jamais calculer au fil de la navigation (un Pi qui
|
||||
tient aussi d'autres services), deux variables inversent la charge :
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- LIDAR_TILE_CACHE_ONLY=1 # la navigation ne rend plus rien
|
||||
- LIDAR_TILE_BACKGROUND=1 # une tâche de fond entretient la pyramide
|
||||
```
|
||||
|
||||
- **`LIDAR_TILE_CACHE_ONLY=1`** — une tuile absente ou périmée est servie
|
||||
transparente avec `X-Tile-Pending: 1` et `Cache-Control: no-store` (le
|
||||
navigateur la redemande : dès que la maintenance l'a rendue, elle apparaît).
|
||||
Plus aucun rendu, ni rapatriement amont, sur le chemin des requêtes.
|
||||
- **`LIDAR_TILE_BACKGROUND=1`** — un sondeur rescane les dalles à intervalle
|
||||
régulier (`LIDAR_TILE_BACKGROUND_INTERVAL`, 120 s) ; chaque dalle nouvelle ou
|
||||
régénérée (le worker vient de produire, le cache à la demande vient de
|
||||
rapatrier) met sa pyramide en file d'attente. Des rendeurs à basse priorité
|
||||
(`os.nice`) la vident au rythme d'une tuile par
|
||||
`LIDAR_TILE_BACKGROUND_PAUSE` seconde (1 s). Premier démarrage : TOUTES les
|
||||
dalles sont inconnues, la pyramide se reconstruit entière — les tuiles déjà
|
||||
fraîches ne coûtent qu'un `stat`. Les niveaux partent en file du plus petit
|
||||
zoom au plus grand : la carte se remplit d'abord grossièrement.
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
|---|---|---|
|
||||
| `LIDAR_TILE_BACKGROUND_MAX_Z` | `16` | niveau maximal entretenu en fond (le natif 17–19 reste au pré-chauffage manuel) |
|
||||
| `LIDAR_TILE_BACKGROUND_SCALE` | `2` | tuiles entretenues : 2 = 512 px (celles de l'interface) |
|
||||
| `LIDAR_TILE_BACKGROUND_FMT` | `webp` | format des tuiles entretenues (celui de l'interface) |
|
||||
| `LIDAR_TILE_BACKGROUND_PAUSE` | `1.0` | pause (s) entre deux rendus — le levier de la discrétion |
|
||||
| `LIDAR_TILE_BACKGROUND_INTERVAL` | `120` | secondes entre deux scans des dalles |
|
||||
| `LIDAR_TILE_BACKGROUND_QUEUE_MAX` | `4096` | file d'attente bornée (au-delà : ignoré jusqu'au prochain scan) |
|
||||
|
||||
Pilotage : `GET /api/map/background` (état, compteurs, file), `POST
|
||||
/api/map/background` (scan immédiat), `POST /api/map/warm` (pré-calcul manuel
|
||||
exhaustif, tous zooms/formats, cf. ci-dessous). Enfin, plafonner le conteneur
|
||||
lui-même (`mem_limit` + `memswap_limit` dans un override compose) garantit
|
||||
qu'un rendu déréglé ne peut plus emporter la machine : le tueur OOM ne
|
||||
toucherait que `lidar-maps`.
|
||||
|
||||
## Pré-chauffage
|
||||
|
||||
```bash
|
||||
|
||||
@ -18,6 +18,19 @@ Les tuiles sont rendues à la demande puis mises en cache sur disque
|
||||
(`output/index_xyz/`). En mode deux machines, `LIDAR_MAPS_URL` désigne un
|
||||
serveur amont : une tuile absente localement y est rapatriée (quelques dizaines
|
||||
de Ko) au lieu de rapatrier les dalles entières.
|
||||
|
||||
Sur une petite machine (Raspberry Pi), deux réglages inversent la charge :
|
||||
|
||||
- `LIDAR_TILE_CACHE_ONLY=1` — la navigation ne rend plus RIEN : une tuile
|
||||
absente est servie transparente (`X-Tile-Pending: 1`, jamais mémorisée par
|
||||
le navigateur) et apparaît après passage de la maintenance ;
|
||||
- `LIDAR_TILE_BACKGROUND=1` — une tâche de fond surveille les dalles
|
||||
(nouvelles ou régénérées) et met leur pyramide à jour en file d'attente,
|
||||
un rendu à la fois, à basse priorité (`os.nice`) et cadencé
|
||||
(`LIDAR_TILE_BACKGROUND_PAUSE`). Premier démarrage = reconstruction de
|
||||
toute la pyramide jusqu'à `LIDAR_TILE_BACKGROUND_MAX_Z` (les tuiles déjà
|
||||
fraîches ne coûtent qu'un `stat`). `POST /api/map/background` force un scan,
|
||||
`POST /api/map/warm` reste le pré-calcul manuel exhaustif.
|
||||
"""
|
||||
|
||||
import json
|
||||
@ -30,6 +43,9 @@ import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.sax.saxutils as _xml
|
||||
from collections import deque
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@ -59,6 +75,201 @@ ATTRIBUTION_TEXT = re.sub(r"<[^>]+>", "", ATTRIBUTION).replace("©", "©").r
|
||||
TILE_WORKERS = max(1, int(os.environ.get("LIDAR_TILE_WORKERS", "2") or 2))
|
||||
_render_sem = threading.Semaphore(TILE_WORKERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance de la pyramide en tâche de fond (petites machines)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `LIDAR_TILE_BACKGROUND=1` + `LIDAR_TILE_CACHE_ONLY=1` inversent la charge :
|
||||
# la navigation ne rend plus rien (lecture cache seule), une sondeur rescane
|
||||
# les dalles à intervalle régulier et chaque dalle nouvelle/régénérée met sa
|
||||
# pyramide de tuiles en file ; des rendeurs à basse priorité (os.nice) la
|
||||
# vident au rythme d'une tuile par LIDAR_TILE_BACKGROUND_PAUSE secondes.
|
||||
# Premier passage = dalle inconnue : toute la pyramide est reconstruite (les
|
||||
# tuiles déjà fraîches ne coûtent qu'un stat).
|
||||
|
||||
def _env_flag(name):
|
||||
"""Indicateur d'environnement (1/true/yes/on/oui). Relu à chaque appel :
|
||||
testable sans recharger le module."""
|
||||
return (os.environ.get(name, "").strip().lower()
|
||||
in ("1", "true", "yes", "on", "oui"))
|
||||
|
||||
|
||||
def _bg_conf():
|
||||
"""Réglages de la maintenance (relus au démarrage des fils, cf. doc)."""
|
||||
return {
|
||||
"interval": max(10.0, float(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_INTERVAL", "120") or 120)),
|
||||
"max_z": max(tiles_mod.TILE_MIN_Z, int(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_MAX_Z", "16") or 16)),
|
||||
"scale": 1 if (os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_SCALE", "2").strip() == "1") else 2,
|
||||
"fmt": (os.environ.get("LIDAR_TILE_BACKGROUND_FMT", "webp")
|
||||
or "webp").strip(),
|
||||
"pause": max(0.0, float(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_PAUSE", "1.0") or 1.0)),
|
||||
"queue_max": max(64, int(os.environ.get(
|
||||
"LIDAR_TILE_BACKGROUND_QUEUE_MAX", "4096") or 4096)),
|
||||
}
|
||||
|
||||
|
||||
_bg = {"threads": [], "stop": threading.Event(), "queue": deque(),
|
||||
"queued": set(), "guard": threading.Lock(), "snapshot": None,
|
||||
"running": False,
|
||||
"stats": {"rendues": 0, "a_jour": 0, "vides": 0, "scans": 0,
|
||||
"dernier_scan": None, "ajouts_dernier_scan": 0}}
|
||||
|
||||
|
||||
def _bg_enqueue(items):
|
||||
"""Ajoute des tuiles (layer, z, x, y) à la file : dédupliquée, bornée."""
|
||||
added = 0
|
||||
with _bg["guard"]:
|
||||
for item in items:
|
||||
if item in _bg["queued"] or len(_bg["queue"]) >= _bg_conf()["queue_max"]:
|
||||
continue
|
||||
_bg["queue"].append(item)
|
||||
_bg["queued"].add(item)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def _bg_pop():
|
||||
with _bg["guard"]:
|
||||
if _bg["queue"]:
|
||||
item = _bg["queue"].popleft()
|
||||
_bg["queued"].discard(item)
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def background_scan(output_dir=None):
|
||||
"""Détecte les dalles nouvelles ou régénérées, met leurs tuiles en file.
|
||||
|
||||
Retourne le nombre de tuiles ajoutées. La comparaison se fait sur le
|
||||
mtime le plus récent des sources de chaque dalle : une dalle régénérée
|
||||
n'invalide que ses propres tuiles (même règle que le cache).
|
||||
"""
|
||||
output_dir = Path(output_dir or OUTPUT_DIR)
|
||||
conf = _bg_conf()
|
||||
snapshot = {}
|
||||
changed = {}
|
||||
for layer, per_cell in tiles_mod.source_index(output_dir, force=True).items():
|
||||
for (col, row), tiers in per_cell.items():
|
||||
newest = 0.0
|
||||
for group in tiers:
|
||||
for src in group:
|
||||
m = src.mtime()
|
||||
if m and m > newest:
|
||||
newest = m
|
||||
snapshot[(layer, col, row)] = newest
|
||||
if (_bg["snapshot"] is not None
|
||||
and _bg["snapshot"].get((layer, col, row)) == newest):
|
||||
continue
|
||||
changed.setdefault(layer, []).append((col, row))
|
||||
_bg["snapshot"] = snapshot
|
||||
items = []
|
||||
for layer, cells in changed.items():
|
||||
for col, row in cells:
|
||||
bbox = tiles_mod.cell_bounds_wgs84(col, row)
|
||||
for z in range(tiles_mod.TILE_MIN_Z, conf["max_z"] + 1):
|
||||
if not tiles_mod.zoom_supported(z, conf["scale"]):
|
||||
continue
|
||||
for x, y in tiles_mod.tiles_in_bounds(bbox, z):
|
||||
items.append((layer, z, x, y))
|
||||
items.sort(key=lambda t: (t[1], t[0], t[2], t[3])) # bas niveaux d'abord
|
||||
added = _bg_enqueue(items)
|
||||
_bg["stats"]["scans"] += 1
|
||||
_bg["stats"]["dernier_scan"] = time.time()
|
||||
_bg["stats"]["ajouts_dernier_scan"] = added
|
||||
cells_changed = sum(len(v) for v in changed.values())
|
||||
if items:
|
||||
logger.info(f"Maintenance des tuiles : {cells_changed} dalle(s) "
|
||||
f"modifiée(s), {added} tuile(s) en file")
|
||||
return added
|
||||
|
||||
|
||||
def _bg_process_one():
|
||||
"""Traite une tuile de la file. Vrai si elle a dû être rendue."""
|
||||
item = _bg_pop()
|
||||
if item is None:
|
||||
return False
|
||||
layer, z, x, y = item
|
||||
conf = _bg_conf()
|
||||
_data, state = tiles_mod.cached_tile(OUTPUT_DIR, layer, z, x, y,
|
||||
conf["scale"], conf["fmt"])
|
||||
if state != "pending":
|
||||
_bg["stats"]["a_jour" if state == "fresh" else "vides"] += 1
|
||||
return False
|
||||
tiles_mod.get_tile(OUTPUT_DIR, layer, z, x, y, conf["scale"], conf["fmt"])
|
||||
_bg["stats"]["rendues"] += 1
|
||||
return True
|
||||
|
||||
|
||||
def _bg_worker():
|
||||
try:
|
||||
os.nice(10) # consommation minimale : le rendu cède aux autres usages
|
||||
except (AttributeError, OSError): # pragma: no cover — nice indisponible
|
||||
pass
|
||||
while not _bg["stop"].is_set():
|
||||
try:
|
||||
rendered = _bg_process_one()
|
||||
except Exception as e: # noqa: BLE001 — une tuile n'arrête pas la file
|
||||
logger.warning(f"Maintenance des tuiles : rendu impossible ({e})")
|
||||
rendered = False
|
||||
if rendered:
|
||||
_bg["stop"].wait(_bg_conf()["pause"]) # cadence : un rendu, une pause
|
||||
else:
|
||||
_bg["stop"].wait(0.5)
|
||||
|
||||
|
||||
def _bg_poller():
|
||||
while not _bg["stop"].is_set():
|
||||
try:
|
||||
background_scan()
|
||||
except Exception as e: # noqa: BLE001 — prochain scan dans INTERVAL
|
||||
logger.warning(f"Maintenance des tuiles : scan impossible ({e})")
|
||||
_bg["stop"].wait(_bg_conf()["interval"])
|
||||
|
||||
|
||||
def start_background():
|
||||
"""Démarre la maintenance : 1 sondeur + TILE_WORKERS rendeurs cadencés."""
|
||||
if _bg["running"]:
|
||||
return
|
||||
_bg["stop"].clear()
|
||||
_bg["running"] = True
|
||||
threads = [threading.Thread(target=_bg_poller, daemon=True,
|
||||
name="lidar-bg-scan")]
|
||||
threads += [threading.Thread(target=_bg_worker, daemon=True,
|
||||
name=f"lidar-bg-render-{i}")
|
||||
for i in range(TILE_WORKERS)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
_bg["threads"] = threads
|
||||
conf = _bg_conf()
|
||||
logger.info(f"Maintenance des tuiles active : z ≤ {conf['max_z']} "
|
||||
f"@{conf['scale']}x {conf['fmt']}, scan toutes les "
|
||||
f"{conf['interval']:.0f} s, pause {conf['pause']:.1f} s "
|
||||
f"entre rendus")
|
||||
|
||||
|
||||
def stop_background():
|
||||
_bg["stop"].set()
|
||||
_bg["running"] = False
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _transparent_tile(scale=1, fmt="png"):
|
||||
return tiles_mod.transparent_tile(scale, fmt)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app):
|
||||
if _env_flag("LIDAR_TILE_BACKGROUND"):
|
||||
start_background()
|
||||
yield
|
||||
stop_background()
|
||||
|
||||
|
||||
_LAYER_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
_NAME_RE = re.compile(r"^(\d+)(@2x)?\.(png|webp|avif)$")
|
||||
_MAX_UPSTREAM_BYTES = 8 * 1024 * 1024
|
||||
@ -70,7 +281,7 @@ if not logger.handlers:
|
||||
logger.addHandler(_h)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="Carte LiDAR — tuiles XYZ")
|
||||
app = FastAPI(title="Carte LiDAR — tuiles XYZ", lifespan=_lifespan)
|
||||
|
||||
# Assets de l'interface : bâchés dans l'image au build, régénérés ici en repli
|
||||
# (dev, paquet installé sans bake).
|
||||
@ -255,14 +466,28 @@ async def tile(layer: str, z: int, x: int, name: str, v: Optional[str] = None):
|
||||
if not (0 <= x < n and 0 <= y < n):
|
||||
raise HTTPException(404, "tuile hors grille")
|
||||
|
||||
if _env_flag("LIDAR_TILE_CACHE_ONLY"):
|
||||
# Mode cache seule : aucune navigation ne déclenche de rendu (ni amont).
|
||||
data, state = await run_in_threadpool(
|
||||
tiles_mod.cached_tile, OUTPUT_DIR, layer, z, x, y, scale, fmt)
|
||||
pending = state == "pending"
|
||||
empty = state == "empty"
|
||||
else:
|
||||
data = await run_in_threadpool(_tile_bytes, layer, z, x, y, scale, fmt)
|
||||
pending = False
|
||||
empty = data is None
|
||||
if empty:
|
||||
data = tiles_mod.transparent_tile(scale, fmt)
|
||||
if empty or pending:
|
||||
data = _transparent_tile(scale, fmt)
|
||||
headers = dict(_cors_headers())
|
||||
if pending:
|
||||
# Tuile à venir (rendue par la maintenance) : jamais mémorisée par le
|
||||
# navigateur, la prochaine navigation la redemande et la trouve prête.
|
||||
headers["Cache-Control"] = "no-store"
|
||||
headers["X-Tile-Pending"] = "1"
|
||||
else:
|
||||
headers["Cache-Control"] = ("public, max-age=31536000, immutable" if v
|
||||
else "public, max-age=300, must-revalidate")
|
||||
headers["X-Tile-Empty"] = "1" if empty else "0"
|
||||
headers["X-Tile-Empty"] = "1" if (empty or pending) else "0"
|
||||
return Response(content=data, media_type=_MEDIA[fmt], headers=headers)
|
||||
|
||||
|
||||
@ -586,6 +811,24 @@ def map_warm_status():
|
||||
return dict(_warm)
|
||||
|
||||
|
||||
@app.get("/api/map/background")
|
||||
def background_status():
|
||||
"""État de la maintenance de fond : file, compteurs, configuration."""
|
||||
with _bg["guard"]:
|
||||
queued = len(_bg["queue"])
|
||||
return {"active": _bg["running"], "en_file": queued,
|
||||
"configuration": _bg_conf(), "stats": dict(_bg["stats"])}
|
||||
|
||||
|
||||
@app.post("/api/map/background")
|
||||
def background_trigger():
|
||||
"""Force un scan immédiat des dalles (rendu ensuite en tâche de fond)."""
|
||||
if not _env_flag("LIDAR_TILE_BACKGROUND"):
|
||||
raise HTTPException(409, "maintenance de fond inactive "
|
||||
"(LIDAR_TILE_BACKGROUND)")
|
||||
return {"tuiles_en_file": background_scan()}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True, "layers": len(tiles_mod.available_layers(OUTPUT_DIR))}
|
||||
|
||||
@ -530,3 +530,68 @@ def test_worker_limits_are_configurable(monkeypatch):
|
||||
monkeypatch.delenv("LIDAR_TILE_FETCH_WORKERS", raising=False)
|
||||
importlib.reload(tiles)
|
||||
importlib.reload(mapserve)
|
||||
|
||||
|
||||
def _reset_bg(mapserve):
|
||||
mapserve._bg["queue"].clear()
|
||||
mapserve._bg["queued"].clear()
|
||||
mapserve._bg["snapshot"] = None
|
||||
mapserve._bg["stats"].update(rendues=0, a_jour=0, vides=0, scans=0,
|
||||
dernier_scan=None, ajouts_dernier_scan=0)
|
||||
|
||||
|
||||
def test_tile_cache_only_never_renders(tmp_path, monkeypatch):
|
||||
"""Mode cache seule : tuile manquante = transparente non mémorisable."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
|
||||
tiles.source_index(tmp_path, force=True)
|
||||
monkeypatch.setenv("LIDAR_TILE_CACHE_ONLY", "1")
|
||||
z = 15
|
||||
x, y = _tile_of_cell(1054, 6882, z)
|
||||
resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png"))
|
||||
assert resp.headers["X-Tile-Pending"] == "1"
|
||||
assert resp.headers["X-Tile-Empty"] == "1"
|
||||
assert resp.headers["Cache-Control"] == "no-store"
|
||||
assert not tiles.tile_cache_path(tmp_path, "slope", z, x, y).exists()
|
||||
# Une fois rendue par ailleurs : servie depuis le cache, mémorisable
|
||||
tiles.get_tile(tmp_path, "slope", z, x, y)
|
||||
resp = asyncio.run(mapserve.tile("slope", z, x, f"{y}.png", v="s"))
|
||||
assert resp.headers["X-Tile-Empty"] == "0"
|
||||
assert "X-Tile-Pending" not in resp.headers
|
||||
assert "immutable" in resp.headers["Cache-Control"]
|
||||
|
||||
|
||||
def test_background_scan_detects_and_fills(tmp_path, monkeypatch):
|
||||
"""La maintenance détecte les dalles, remplit la pyramide, puis se tait."""
|
||||
import lidar_pipeline.mapserve as mapserve
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
monkeypatch.setattr(mapserve, "OUTPUT_DIR", tmp_path)
|
||||
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_MAX_Z", "14")
|
||||
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_SCALE", "1")
|
||||
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_FMT", "png")
|
||||
monkeypatch.setenv("LIDAR_TILE_BACKGROUND_PAUSE", "0")
|
||||
_reset_bg(mapserve)
|
||||
try:
|
||||
# Premier scan : tout est nouveau, bas niveaux en tête de file
|
||||
assert mapserve.background_scan(tmp_path) > 0
|
||||
zs = [t[1] for t in mapserve._bg["queue"]]
|
||||
assert zs == sorted(zs) and max(zs) <= 14
|
||||
processed = 0
|
||||
while mapserve._bg["queue"] and processed < 1000:
|
||||
mapserve._bg_process_one()
|
||||
processed += 1
|
||||
assert not mapserve._bg["queue"]
|
||||
assert mapserve._bg["stats"]["rendues"] > 0
|
||||
x, y = _tile_of_cell(1054, 6882, 14)
|
||||
assert tiles.tile_cache_path(
|
||||
tmp_path, "slope", 14, x, y, 1, "png").exists()
|
||||
# Rien n'a changé : plus aucun ajout
|
||||
assert mapserve.background_scan(tmp_path) == 0
|
||||
# Une dalle nouvelle (worker) : sa pyramide repart seule en file
|
||||
_make_dalle(tmp_path, 1055, 6882, ["slope"])
|
||||
assert mapserve.background_scan(tmp_path) > 0
|
||||
finally:
|
||||
_reset_bg(mapserve)
|
||||
|
||||
@ -487,3 +487,20 @@ def test_remote_index_failure_keeps_local(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tiles, "_remote_payload", lambda force=False: None)
|
||||
layers = tiles.source_index(tmp_path, force=True)
|
||||
assert set(layers) == {"slope"}
|
||||
|
||||
|
||||
def test_cached_tile_states(tmp_path):
|
||||
"""`cached_tile` : lecture cache seule — fresh / pending / empty, sans rendu."""
|
||||
from lidar_pipeline import tiles
|
||||
_make_dalle(tmp_path, 1054, 6882, ["slope"])
|
||||
x, y = _tile_of_cell(1054, 6882, 14)
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
|
||||
assert state == "pending" and data is None
|
||||
assert not tiles.tile_cache_path(tmp_path, "slope", 14, x, y).exists()
|
||||
tiles.get_tile(tmp_path, "slope", 14, x, y) # rendu (maintenance, warm…)
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, x, y)
|
||||
assert state == "fresh" and data
|
||||
# Hors données : état empty + marqueur posé, toujours sans rendu
|
||||
data, state = tiles.cached_tile(tmp_path, "slope", 14, 0, 0)
|
||||
assert state == "empty" and data is None
|
||||
assert tiles.empty_marker_exists(tmp_path, "slope", 14, 0, 0)
|
||||
|
||||
@ -325,6 +325,15 @@ def _cell_bounds(col, row):
|
||||
return (col * 1000.0, (row - 1) * 1000.0, (col + 1) * 1000.0, row * 1000.0)
|
||||
|
||||
|
||||
def cell_bounds_wgs84(col, row):
|
||||
"""Emprise WGS84 [ouest, sud, est, nord] d'une dalle LHD (4 coins L93)."""
|
||||
min_x, min_y, max_x, max_y = _cell_bounds(col, row)
|
||||
xs = (min_x, min_x, max_x, max_x)
|
||||
ys = (min_y, max_y, min_y, max_y)
|
||||
lons, lats = _transformer("EPSG:2154", "EPSG:4326").transform(xs, ys)
|
||||
return (min(lons), min(lats), max(lons), max(lats))
|
||||
|
||||
|
||||
def _dir_mtime(path):
|
||||
try:
|
||||
return path.stat().st_mtime
|
||||
@ -780,6 +789,37 @@ def empty_marker_exists(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
scale, fmt).with_suffix(".empty").exists()
|
||||
|
||||
|
||||
def cached_tile(output_dir, layer, z, x, y, scale=1, fmt="png"):
|
||||
"""Tuile depuis le cache UNIQUEMENT — jamais de rendu (mode cache seule).
|
||||
|
||||
Retourne ``(data, état)`` :
|
||||
- ``"fresh"`` : tuile en cache et plus récente que ses dalles sources ;
|
||||
- ``"empty"`` : aucune source n'intersecte (marqueur `.empty` posé) ;
|
||||
- ``"pending"``: donnée présente mais tuile absente ou périmée — à rendre
|
||||
ailleurs (tâche de fond, pré-chauffage), pas au fil de la navigation.
|
||||
"""
|
||||
cache = tile_cache_path(output_dir, layer, z, x, y, scale, fmt)
|
||||
empty = cache.with_suffix(".empty")
|
||||
sources = _contributing(output_dir, layer, z, x, y, scale)
|
||||
if not sources:
|
||||
if not empty.exists():
|
||||
_write_atomic(empty, b"")
|
||||
return None, "empty"
|
||||
if empty.exists():
|
||||
return None, "empty"
|
||||
newest = 0.0
|
||||
for src in sources:
|
||||
m = src.mtime()
|
||||
if m and m > newest:
|
||||
newest = m
|
||||
try:
|
||||
if cache.stat().st_mtime >= newest:
|
||||
return cache.read_bytes(), "fresh"
|
||||
except OSError:
|
||||
pass
|
||||
return None, "pending"
|
||||
|
||||
|
||||
def transparent_tile(scale=1, fmt="png"):
|
||||
"""Tuile entièrement transparente (zone sans donnée)."""
|
||||
from PIL import Image
|
||||
|
||||
Reference in New Issue
Block a user