OverviewFilesCommitsIssuesDocsReleasesPeers

terrain-lookdev commits / 9cabe77

explore: a separate page for moving around the infinite heightmap
A second lookdev, deliberately without erosion. This one is only about navigation, streaming and what generation actually costs while you move. tiles.py is the store: disk-cached int16 tiles, a single background worker, and a priority queue re-sorted by distance from the view on every request, so walking somewhere new abandons the old prefetch order instead of finishing it. WHY IT IS SHAPED THIS WAY, all from measurements in this session: ONE worker, one generation in flight. A tile is a multi-second 85-92% GPU burst. This is the whole contention mitigation, and it is why the page says "one tile generates at a time, by design" rather than trying to look fast. Tiles are generated in ANY order with no knowledge of neighbours. Legitimate only because decomposition-independence was MEASURED: one 512 request vs four 256 quadrants with a cold cache differ by max 1 m, and the step across a join equals the step inside a tile. ONE ramp for every tile, never per-tile. A per-tile ramp makes the map a patchwork that reads as terrain discontinuity -- the same misread as upstream issue #19, and the same bug this repo shipped in the other page yesterday. /api/tile.png NEVER blocks on the GPU. It returns 204 and queues. A request path that waits would stall panning for seconds, and the point of the page is to feel what streaming is like. Disk cache, not the 100 MB in-memory LRU upstream uses. A 256 tile is 128 KB of int16, so ~500 x 500 km is about 340 MB. FIRST THING THE PAGE REVEALED, which is the optimisation lead: At 180 km across, 312 tiles are visible and each 7.68 km tile lands on ~40 screen px -- so it was queueing 343 tiles of full 30 m/px detail to draw them at thumbnail size. Tiles below 48 on-screen px are now neither requested nor faked; the HUD says "too small, not requesting". That band is exactly where the coarse stage belongs: 7.7 km/px, already a separately-indexable InfiniteTensor (world_pipeline.py:1425), just not exposed over HTTP yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9cabe778265436553cad94a3cad77bf041c91f90
by plankatron <noreply@anthropic.com> · 2026-08-31T19:03:35.000Z

Changed files (4) +480 -0

__pycache__/tiles.cpython-312.pyc +0 -0
(no content changes — binary or rename)
explore.html +238 -0
……@@ -0,0 +1,238 @@
1<!doctype html>
2<meta charset="utf-8">
3<title>Infinite Terrain — Explore</title>
4<style>
5:root{
6 --bg:#0f141a; --panel:#171d24; --edge:#26313a; --edge2:#354350;
7 --ink:#e3e9e6; --dim:#8b9aa1; --accent:#74c2cd; --warn:#d7a45c; --ok:#77c39a;
8 font-family:"IBM Plex Sans",system-ui,sans-serif;
9}
10*{box-sizing:border-box}
11html,body{margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-size:13px}
12#map{position:absolute;inset:0;cursor:grab;touch-action:none}
13#map:active{cursor:grabbing}
14canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
15
16.hud{position:absolute;background:var(--panel);border:1px solid var(--edge);
17 border-radius:4px;padding:11px 13px;font-family:"IBM Plex Mono",ui-monospace,monospace;
18 font-size:11px;line-height:1.65;white-space:pre;pointer-events:none;
19 font-variant-numeric:tabular-nums}
20#stats{top:12px;left:12px}
21#help{bottom:12px;left:12px;color:var(--dim);font-size:10.5px}
22#ctl{top:12px;right:12px;pointer-events:auto;white-space:normal;width:214px}
23#ctl label{display:grid;grid-template-columns:1fr auto;gap:2px 8px;margin:7px 0;
24 font-family:"IBM Plex Sans",sans-serif;font-size:11.5px;align-items:center}
25#ctl input[type=range]{grid-column:1/-1;width:100%;accent-color:var(--accent)}
26#ctl .v{color:var(--accent);font-family:"IBM Plex Mono",monospace}
27#ctl button{width:100%;margin-top:7px;background:#22303a;color:var(--ink);
28 border:1px solid var(--edge2);border-radius:3px;padding:5px;
29 font:inherit;font-size:11px;cursor:pointer}
30#ctl button:hover{border-color:var(--accent)}
31#ctl hr{border:0;border-top:1px solid var(--edge);margin:11px 0}
32.k{color:var(--dim)} .g{color:var(--ok)} .w{color:var(--warn)} .a{color:var(--accent)}
33</style>
34
35<div id="map"><canvas id="cv"></canvas></div>
36<div class="hud" id="stats"></div>
37<div class="hud" id="ctl">
38 <label>zoom <span class="v" id="v_zoom">1.00</span>
39 <input type="range" id="zoom" min="-3" max="1.6" step="0.05" value="0"></label>
40 <label>prefetch rings <span class="v" id="v_pre">1</span>
41 <input type="range" id="pre" min="0" max="3" step="1" value="1"></label>
42 <label><input type="checkbox" id="grid" checked style="width:auto"> tile grid + state</label>
43 <hr>
44 <button id="go0">origin (0,0)</button>
45 <button id="gomtn">the mountain region</button>
46 <button id="clearq">stop prefetching</button>
47</div>
48<div class="hud" id="help">drag to pan · wheel to zoom · one tile generates at a time, by design</div>
49
50<script>
51const TILE_PX = 256; // must match the server's tile_px
52const M_PER_PX = 30; // model native
53const TILE_M = TILE_PX * M_PER_PX; // 7.68 km per tile
54
55const cv = document.getElementById("cv"), ctx = cv.getContext("2d", {alpha:false});
56const $ = id => document.getElementById(id);
57
58// view: world position (in model px) at screen centre, and scale (screen px per model px)
59let view = {x: 0, y: 2048 * 1 + 128, scale: 0.25};
60const imgs = new Map(); // "tx,ty" -> Image (loaded) or "pending"
61let tileState = {}; // "tx,ty" -> cached|disk|queued|busy|missing|error
62let stats = {};
63let dirty = true;
64
65function resize() {
66 const r = devicePixelRatio || 1;
67 cv.width = innerWidth * r; cv.height = innerHeight * r;
68 ctx.setTransform(r, 0, 0, r, 0, 0);
69 ctx.imageSmoothingEnabled = false;
70 dirty = true;
71}
72addEventListener("resize", resize);
73
74function visibleTiles() {
75 const w = innerWidth / view.scale, h = innerHeight / view.scale;
76 const x0 = view.x - w/2, y0 = view.y - h/2;
77 const t0x = Math.floor(x0 / TILE_PX), t1x = Math.floor((x0 + w) / TILE_PX);
78 const t0y = Math.floor(y0 / TILE_PX), t1y = Math.floor((y0 + h) / TILE_PX);
79 const out = [];
80 for (let ty = t0y; ty <= t1y; ty++)
81 for (let tx = t0x; tx <= t1x; tx++) out.push([tx, ty]);
82 return out;
83}
84
85function tileImage(tx, ty) {
86 const k = tx + "," + ty;
87 if (imgs.has(k)) return imgs.get(k);
88 const im = new Image();
89 im.onload = () => { imgs.set(k, im); dirty = true; };
90 im.onerror = () => { imgs.delete(k); }; // 204 = not ready yet
91 im.src = `/api/tile.png?tx=${tx}&ty=${ty}&view=shaded-height`;
92 imgs.set(k, "pending");
93 return "pending";
94}
95
96function draw() {
97 ctx.fillStyle = "#0d1114";
98 ctx.fillRect(0, 0, innerWidth, innerHeight);
99 const s = view.scale, size = TILE_PX * s;
100 const cxs = innerWidth/2, cys = innerHeight/2;
101 const showGrid = $("grid").checked;
102
103 for (const [tx, ty] of visibleTiles()) {
104 const sx = cxs + (tx * TILE_PX - view.x) * s;
105 const sy = cys + (ty * TILE_PX - view.y) * s;
106 const im = tileImage(tx, ty);
107 if (im && im !== "pending") {
108 ctx.drawImage(im, sx, sy, size + 1, size + 1);
109 } else {
110 const st = tileState[tx + "," + ty] || "missing";
111 ctx.fillStyle = st === "busy" ? "#1d3038" : "#141a20";
112 ctx.fillRect(sx, sy, size, size);
113 if (size < 48) { // below the request threshold: say so, do not pretend
114 ctx.fillStyle = "#243039";
115 ctx.fillRect(sx + 1, sy + 1, size - 2, size - 2);
116 }
117 }
118 if (showGrid) {
119 const st = tileState[tx + "," + ty] || "missing";
120 ctx.strokeStyle = st === "busy" ? "#74c2cd"
121 : st === "queued" ? "#d7a45c"
122 : (st === "cached" || st === "disk") ? "#2b3b45" : "#3a2b2b";
123 ctx.lineWidth = st === "busy" ? 2 : 1;
124 ctx.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1);
125 if (size > 90) {
126 ctx.fillStyle = "#8b9aa1";
127 ctx.font = "10px 'IBM Plex Mono', monospace";
128 ctx.fillText(`${tx},${ty}`, sx + 6, sy + 14);
129 if (st === "busy" || st === "queued") ctx.fillText(st, sx + 6, sy + 27);
130 }
131 }
132 }
133 dirty = false;
134}
135
136function frame() { if (dirty) draw(); requestAnimationFrame(frame); }
137requestAnimationFrame(frame);
138
139// ---- prefetch: ask for what is visible, plus N rings, centred on the view ----
140async function poll() {
141 const vis = visibleTiles();
142 const rings = +$("pre").value;
143 // DO NOT queue what you cannot see the detail of. At low zoom a 7.68 km tile
144 // lands on ~40 screen px, so generating 30 m/px for it is pure waste -- and
145 // the queue explodes (343 deep on first load). Below MIN_PX the tile is drawn
146 // as a placeholder and never requested; that band is exactly where the coarse
147 // stage (7.7 km/px, cheap) belongs, and is the next thing to wire in.
148 const MIN_PX = 48;
149 const onScreen = TILE_PX * view.scale;
150 if (onScreen < MIN_PX) {
151 try {
152 const r = await fetch(`/api/tiles/state?tiles=`);
153 stats = (await r.json()).stats; hud();
154 } catch (e) {}
155 return;
156 }
157 const want = new Set(vis.map(t => t.join(",")));
158 if (rings > 0) {
159 for (const [tx, ty] of vis)
160 for (let dy = -rings; dy <= rings; dy++)
161 for (let dx = -rings; dx <= rings; dx++) want.add((tx+dx) + "," + (ty+dy));
162 }
163 const list = [...want].join(";");
164 const ctx_ = [Math.floor(view.x / TILE_PX), Math.floor(view.y / TILE_PX)];
165 try {
166 if (!STOPPED) {
167 await fetch(`/api/tiles/request?tiles=${encodeURIComponent(list)}` +
168 `&cx=${ctx_[0]}&cy=${ctx_[1]}`);
169 }
170 const r = await fetch(`/api/tiles/state?tiles=${encodeURIComponent(list)}`);
171 const j = await r.json();
172 tileState = j.state; stats = j.stats;
173 // retry any tile that has become available since we last failed on it
174 for (const [k, st] of Object.entries(tileState))
175 if ((st === "cached" || st === "disk") && !imgs.has(k)) {
176 const [tx, ty] = k.split(",").map(Number); tileImage(tx, ty);
177 }
178 dirty = true;
179 hud();
180 } catch (e) { /* keep polling */ }
181}
182setInterval(poll, 900);
183
184let STOPPED = false;
185$("clearq").onclick = () => {
186 STOPPED = !STOPPED;
187 $("clearq").textContent = STOPPED ? "resume prefetching" : "stop prefetching";
188};
189
190function hud() {
191 const s = stats || {};
192 const km = (TILE_PX * M_PER_PX / 1000).toFixed(2);
193 const wx = (view.x * M_PER_PX / 1000).toFixed(1);
194 const wy = (view.y * M_PER_PX / 1000).toFixed(1);
195 const span = (innerWidth / view.scale * M_PER_PX / 1000).toFixed(1);
196 $("stats").innerHTML =
197`<span class="a">INFINITE DIFFUSION — STREAMING</span>
198<span class="k">centre </span>${wx} , ${wy} km <span class="k">(tile</span> ${Math.floor(view.x/TILE_PX)},${Math.floor(view.y/TILE_PX)}<span class="k">)</span>
199<span class="k">view </span>${span} km across <span class="k">tile</span> ${km} km
200<span class="k">visible </span>${visibleTiles().length} tiles <span class="k">on-screen</span> ${(TILE_PX*view.scale).toFixed(0)} px${TILE_PX*view.scale < 48 ? ' <span class="w">— too small, not requesting</span>' : ''}
201
202<span class="k">generated </span>${s.generated||0} tiles <span class="k">=</span> ${(s.km2||0).toFixed(0)} km²
203<span class="k">avg gen </span>${(s.avg_gen_s||0).toFixed(2)} s/tile ${(s.km2_per_s||0).toFixed(1)} km²/s
204<span class="k">queue </span><span class="${(s.queue||0)>0?'w':'k'}">${s.queue||0}</span> <span class="k">busy</span> <span class="a">${(s.busy||[]).join(" ")||"—"}</span>
205<span class="k">cache </span>${s.disk_tiles||0} on disk, ${s.mem_tiles||0} in memory
206<span class="k">hits </span>${s.from_disk||0} disk, ${s.from_mem||0} mem${(s.errors||0)?`\n<span class="w">errors ${s.errors}</span>`:""}`;
207}
208
209// ---- interaction -----------------------------------------------------------
210let down = false, lx = 0, ly = 0;
211$("map").addEventListener("pointerdown", e => { down = true; lx = e.clientX; ly = e.clientY;
212 $("map").setPointerCapture(e.pointerId); });
213$("map").addEventListener("pointermove", e => {
214 if (!down) return;
215 view.x -= (e.clientX - lx) / view.scale;
216 view.y -= (e.clientY - ly) / view.scale;
217 lx = e.clientX; ly = e.clientY; dirty = true; hud();
218});
219$("map").addEventListener("pointerup", () => { down = false; poll(); });
220$("map").addEventListener("wheel", e => {
221 e.preventDefault();
222 const z = Math.max(-3, Math.min(1.6, Math.log2(view.scale) - Math.sign(e.deltaY) * 0.15));
223 $("zoom").value = z; setZoom(z);
224}, {passive:false});
225
226function setZoom(z) {
227 view.scale = Math.pow(2, z);
228 $("v_zoom").textContent = view.scale.toFixed(3);
229 dirty = true; hud();
230}
231$("zoom").addEventListener("input", () => setZoom(+$("zoom").value));
232$("pre").addEventListener("input", () => { $("v_pre").textContent = $("pre").value; });
233$("grid").addEventListener("change", () => { dirty = true; });
234$("go0").onclick = () => { view.x = 0; view.y = 0; dirty = true; poll(); };
235$("gomtn").onclick = () => { view.x = 128; view.y = 2176; dirty = true; poll(); };
236
237resize(); setZoom(-2); hud(); poll();
238</script>
server.py +93 -0
……@@ -25,11 +25,27 @@ import numpy as np
2525from flask import Flask, Response, request, send_from_directory
2626
2727HERE = os.path.dirname(os.path.abspath(__file__))
28TILE_CACHE = os.environ.get("LOOKDEV_TILE_CACHE",
29 os.path.expanduser("~/.cache/terrain-lookdev/tiles"))
2830DC_TERRAIN = os.path.abspath(os.path.join(HERE, "..", "dc-terrain"))
2931INC = os.path.abspath(os.path.join(HERE, "..", "proc-nodes", "src", "shared",
3032 "erosion_filter.inc"))
3133
3234app = Flask(__name__, static_folder=None)
35_tiles = None # TileStore, built lazily so importing this is cheap
36
37
38def tiles():
39 global _tiles
40 if _tiles is None:
41 from tiles import TileStore
42 def _fetch(i1, j1, n):
43 return fetch_diffusion(app.config["TD_URL"], i1, j1, n)[:2]
44 _tiles = TileStore(_fetch, TILE_CACHE,
45 tile_px=int(app.config.get("TILE_PX", 256)))
46 print(f"tile cache: {TILE_CACHE} tile {_tiles.tile_px} px "
47 f"= {_tiles.tile_px*30/1000:.2f} km")
48 return _tiles
3349
3450# Every knob the renderer will accept, with its type and a sane range. The
3551# server shells out, so this allowlist is also the injection boundary: a key
……@@ -432,6 +448,83 @@ def api_diffusion_bin():
432448 return Response(str(e), status=400, mimetype="text/plain")
433449
434450
451# ---------------------------------------------------------------------------
452# Explore: an infinite pannable map, streamed.
453# ---------------------------------------------------------------------------
454@app.get("/explore")
455def explore():
456 r = send_from_directory(HERE, "explore.html")
457 r.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
458 return r
459
460
461@app.get("/api/tile.png")
462def api_tile():
463 """Return a tile IF it is already available, else 204 and queue it.
464
465 This endpoint never blocks on the GPU. A request path that waits for
466 generation would stall panning for seconds at a time, and the point of the
467 page is to find out what streaming actually feels like."""
468 try:
469 tx = int(request.args["tx"]); ty = int(request.args["ty"])
470 ts = tiles()
471 a = ts.get(tx, ty)
472 if a is None:
473 ts.request([(tx, ty)], (tx, ty))
474 return Response(status=204)
475 # ONE ramp for every tile. A per-tile ramp makes the map a patchwork and
476 # reads as terrain discontinuity -- the same misread as upstream #19.
477 vmin = float(request.args.get("vmin", -4000))
478 vmax = float(request.args.get("vmax", 4000))
479 png, _lo, _hi = colourise(a.astype(np.float64), 30.0,
480 request.args.get("view", "shaded-height"),
481 vmin, vmax)
482 r = Response(png, mimetype="image/png")
483 r.headers["Cache-Control"] = "public, max-age=3600"
484 r.headers["X-Tile"] = f"{tx},{ty}"
485 return r
486 except Exception as e:
487 return Response(str(e), status=400, mimetype="text/plain")
488
489
490@app.get("/api/tiles/request")
491def api_tiles_request():
492 """Queue a batch, prioritised by distance from the view centre."""
493 try:
494 raw = request.args.get("tiles", "")
495 want = []
496 for pair in raw.split(";"):
497 if not pair.strip():
498 continue
499 x, y = pair.split(",")
500 want.append((int(x), int(y)))
501 cx = int(request.args.get("cx", 0)); cy = int(request.args.get("cy", 0))
502 ts = tiles()
503 ts.request(want[:400], (cx, cy))
504 return Response(json.dumps({"queued": len(want), "depth": ts.queue_depth()}),
505 mimetype="application/json")
506 except Exception as e:
507 return Response(str(e), status=400, mimetype="text/plain")
508
509
510@app.get("/api/tiles/state")
511def api_tiles_state():
512 """Per-tile availability for the overlay, plus streaming stats."""
513 try:
514 ts = tiles()
515 out = {}
516 raw = request.args.get("tiles", "")
517 for pair in raw.split(";"):
518 if not pair.strip():
519 continue
520 x, y = [int(v) for v in pair.split(",")]
521 out[f"{x},{y}"] = ts.state(x, y)
522 return Response(json.dumps({"state": out, "stats": ts.summary()}),
523 mimetype="application/json")
524 except Exception as e:
525 return Response(str(e), status=400, mimetype="text/plain")
526
527
435528@app.get("/api/knobs")
436529def api_knobs():
437530 return Response(json.dumps({k: {"kind": v[0], "min": v[1], "max": v[2]}
tiles.py +149 -0
……@@ -0,0 +1,149 @@
1#!/usr/bin/env python3
2"""
3TILE STREAMING — moving around an infinite diffusion heightmap.
4
5Everything here rests on one property, measured earlier in #46 and not assumed:
6tiles are DECOMPOSITION-INDEPENDENT and SEAM-FREE. Fetching a 512 region as one
7request or as four 256 quadrants with a cold cache differs by max 1 m (int16
8quantisation), and the step across a join equals the step inside a tile. So
9tiles can be generated in ANY order, at ANY time, with no knowledge of their
10neighbours -- which is what makes background streaming possible at all, and is
11exactly the property erosion simulation lacks.
12
13DESIGN, and the reasons are measured:
14
15 ONE worker, one generation in flight, ever.
16 A tile is a multi-second 85-92% GPU burst. More than one in flight and the
17 renderer starves. This is the entire contention mitigation.
18
19 LARGE tiles, not small.
20 Cost floor is ~2.4 s regardless of size. A 16 px tile costs 2.41 s and is
21 385x less efficient per km2 than a 1024. Small tiles do not spread the
22 cost, they multiply it. Default 256 (7.68 km) is a compromise with
23 latency; 512 is more efficient if you can wait.
24
25 DISK cache, not just memory.
26 The upstream 'direct' strategy is a 100 MB in-memory LRU, so a revisited
27 region regenerates. Elevation is cached here as int16 npy: a 256 tile is
28 128 KB, so an entire 500 x 500 km area is about 340 MB.
29
30 PRIORITY BY DISTANCE.
31 The queue is re-sorted on every view change, so walking somewhere new
32 abandons the old prefetch order rather than finishing it first.
33"""
34import os, queue, threading, time
35import numpy as np
36
37TILE_M = 30.0 # model native metres per pixel
38
39
40class TileStore:
41 def __init__(self, fetch_fn, cache_dir, tile_px=256, log=print):
42 self.fetch = fetch_fn # (i1, j1, n) -> (elev, climate)
43 self.dir = cache_dir
44 self.tile_px = tile_px
45 self.log = log
46 os.makedirs(self.dir, exist_ok=True)
47 self._mem = {} # (tx,ty) -> np.int16 array
48 self._state = {} # (tx,ty) -> 'cached'|'queued'|'busy'|'error'
49 self._q = queue.PriorityQueue()
50 self._queued = set()
51 self._lock = threading.Lock()
52 self._centre = (0, 0)
53 self._seq = 0
54 self.stats = dict(generated=0, from_disk=0, from_mem=0,
55 gen_seconds=0.0, km2=0.0, errors=0)
56 self._worker = threading.Thread(target=self._run, daemon=True)
57 self._worker.start()
58
59 # ---- addressing --------------------------------------------------------
60 def path(self, tx, ty):
61 return os.path.join(self.dir, f"t_{self.tile_px}_{tx}_{ty}.npy")
62
63 def state(self, tx, ty):
64 k = (tx, ty)
65 if k in self._mem:
66 return "cached"
67 if os.path.exists(self.path(tx, ty)):
68 return "disk"
69 return self._state.get(k, "missing")
70
71 # ---- read --------------------------------------------------------------
72 def get(self, tx, ty):
73 """Return the tile if it is already available. NEVER generates -- the
74 request path must not block on the GPU, or panning stalls the UI."""
75 k = (tx, ty)
76 if k in self._mem:
77 self.stats["from_mem"] += 1
78 return self._mem[k]
79 p = self.path(tx, ty)
80 if os.path.exists(p):
81 a = np.load(p)
82 self._mem[k] = a
83 self.stats["from_disk"] += 1
84 if len(self._mem) > 400: # bounded; disk is the real cache
85 self._mem.pop(next(iter(self._mem)))
86 return a
87 return None
88
89 # ---- queue -------------------------------------------------------------
90 def request(self, tiles, centre):
91 """Ask for these tiles. Priority is distance from centre, recomputed
92 every call, so a new view supersedes the old prefetch order."""
93 self._centre = centre
94 with self._lock:
95 for tx, ty in tiles:
96 k = (tx, ty)
97 if k in self._queued or self.state(tx, ty) in ("cached", "disk"):
98 continue
99 d = (tx - centre[0]) ** 2 + (ty - centre[1]) ** 2
100 self._seq += 1
101 self._q.put((d, self._seq, k))
102 self._queued.add(k)
103 self._state[k] = "queued"
104
105 def queue_depth(self):
106 return self._q.qsize()
107
108 # ---- the single worker -------------------------------------------------
109 def _run(self):
110 while True:
111 _, _, (tx, ty) = self._q.get()
112 with self._lock:
113 self._queued.discard((tx, ty))
114 if self.state(tx, ty) in ("cached", "disk"):
115 self._state.pop((tx, ty), None)
116 continue
117 self._state[(tx, ty)] = "busy"
118 t0 = time.time()
119 try:
120 n = self.tile_px
121 e, _c = self.fetch(tx * n, ty * n, n)
122 a = np.clip(e, -32768, 32767).astype(np.int16)
123 np.save(self.path(tx, ty), a)
124 self._mem[(tx, ty)] = a
125 dt = time.time() - t0
126 self.stats["generated"] += 1
127 self.stats["gen_seconds"] += dt
128 self.stats["km2"] += (n * TILE_M / 1000.0) ** 2
129 self._state.pop((tx, ty), None)
130 self.log(f" tile ({tx},{ty}) in {dt:.2f}s "
131 f"queue {self._q.qsize()}")
132 except Exception as ex:
133 self.stats["errors"] += 1
134 self._state[(tx, ty)] = "error"
135 self.log(f" tile ({tx},{ty}) FAILED: {ex}")
136
137 def summary(self):
138 s = dict(self.stats)
139 s["queue"] = self.queue_depth()
140 s["mem_tiles"] = len(self._mem)
141 try:
142 s["disk_tiles"] = len([f for f in os.listdir(self.dir)
143 if f.startswith(f"t_{self.tile_px}_")])
144 except OSError:
145 s["disk_tiles"] = 0
146 s["avg_gen_s"] = (s["gen_seconds"] / s["generated"]) if s["generated"] else 0.0
147 s["km2_per_s"] = (s["km2"] / s["gen_seconds"]) if s["gen_seconds"] else 0.0
148 s["busy"] = [f"{k[0]},{k[1]}" for k, v in self._state.items() if v == "busy"]
149 return s