Coverage for gws-app/gws/lib/grid/__init__.py: 86%

124 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Map grids: tile pyramid math. 

2 

3A grid is a ``gws.MapGrid``: a CRS, a frame extent, a base resolution and a 

4tile size. The origin is the north-west corner of the frame. Level ``z`` has 

5the resolution ``baseResolution / 2**z``, so levels nest exactly (quad tree) 

6and the ladder has no bottom: any resolution can be served by downsampling 

7from the coarsest level whose resolution does not exceed it. 

8 

9Defaults: projected CRS use the web mercator square as frame and one tile at 

10level 0; geographic CRS use ``(-180, -90, 180, 90)`` and two tiles (2x1) at 

11level 0. Without an explicit base resolution, one tile at level 0 spans the 

12frame height, so that the default resolutions are 

13``156543.03392804097 / 2**z`` metres and ``0.703125 / 2**z`` degrees for a 

14256px tile. Custom frames, base resolutions and tile sizes (tile source 

15grids) are possible; a non-square custom frame needs an explicit base 

16resolution. The frame is an indexing frame and does not have to be inside 

17the area of use of the CRS. 

18 

19A custom grid can be snapped to the default grid of its CRS (``withSnap``): 

20the base resolution becomes a ladder value, the nearest one if given, 

21otherwise the coarsest whose tile spans the extent, and the extent is 

22expanded outward to tile boundaries at that level. A snapped grid is a 

23window on the default grid: its tile ``(x, y, z)`` is the default grid's 

24``(x + ox * 2**z, y + oy * 2**z, z + k)`` for the integer offset ``(ox, oy)`` 

25of the window at level ``k``. 

26 

27Tiles are ``(x, y, z)``, ranges ``(min_x, min_y, max_x, max_y, z)``. 

28 

29Tile matrix sets (``gws.TileMatrixSet``, as in WMTS) describe foreign 

30pyramids: each matrix has its own origin, size and resolution, and the 

31resolutions need not form a ladder. ``matrix_set_for_grid`` expresses a grid 

32as a matrix set, the ``matrix_*`` functions are the matrix counterparts of 

33the grid functions. 

34 

35Example:: 

36 

37 mg = gws.lib.grid.for_crs(gws.lib.crs.get(3857)) 

38 z = gws.lib.grid.level_for_resolution(mg, 10.0) 

39 tr = gws.lib.grid.range_for_extent(mg, (1000000, 6000000, 1010000, 6010000), z) 

40 for mt in gws.lib.grid.enum_tiles(tr): 

41 extent = gws.lib.grid.extent_for_tile(mg, mt) 

42 

43A custom grid from a ``Config``-like set of options:: 

44 

45 mg = gws.lib.grid.new(gws.lib.grid.Options( 

46 crs=gws.lib.crs.get(25832), 

47 extent=(280000, 5200000, 920000, 6100000), 

48 tileSize=512, 

49 )) 

50""" 

51 

52import math 

53from typing import Iterator, Optional 

54 

55import gws 

56import gws.lib.crs 

57 

58RESOLUTION_TOLERANCE = 0.01 

59"""Relative tolerance for a resolution to count as a level's resolution. 

60 

61Requests are often rounded (bboxes with a few decimals), so a resolution slightly above 

62a level's must still map to that level rather than to the next finer one. 

63""" 

64 

65DEFAULT_TILE_SIZE = 256 

66"""Default tile size in pixels.""" 

67 

68 

69class Props(gws.Props): 

70 origin: str 

71 extent: gws.Extent 

72 resolutions: list[float] 

73 tileSize: int 

74 

75 

76 

77 

78class Config(gws.Config): 

79 """Tile grid. (changed in 8.5)""" 

80 

81 crs: Optional[gws.CrsName] 

82 """Grid CRS.""" 

83 extent: Optional[gws.Extent] 

84 """Grid frame extent, with the origin at its north-west corner.""" 

85 baseResolution: Optional[float] 

86 """Resolution at level 0. (added in 8.5)""" 

87 tileSize: Optional[int] 

88 """Tile size in pixels.""" 

89 withSnap: bool = True 

90 """Snap the extent and the base resolution to the default grid of the CRS. (added in 8.5)""" 

91 

92 

93class Options(gws.Data): 

94 """Options for creating a grid with ``new``.""" 

95 

96 crs: gws.Crs 

97 """Grid CRS.""" 

98 extent: Optional[gws.Extent] 

99 """Frame extent. The default depends on the CRS.""" 

100 baseResolution: Optional[float] 

101 """Resolution at level 0. By default, one tile spans the frame height.""" 

102 tileSize: Optional[int] 

103 """Tile size in pixels, ``DEFAULT_TILE_SIZE`` by default.""" 

104 withSnap: Optional[bool] 

105 """Snap a custom extent or base resolution to the default grid of the CRS, ``True`` by default.""" 

106 

107 

108def for_crs(crs: gws.Crs) -> gws.MapGrid: 

109 """Create the default grid for a CRS. 

110 

111 Args: 

112 crs: Grid CRS. 

113 

114 Returns: 

115 A grid with the default frame, base resolution and tile size. 

116 """ 

117 

118 return new(Options(crs=crs)) 

119 

120 

121def new(opts: Options) -> gws.MapGrid: 

122 """Create a grid. 

123 

124 Missing options are filled with the defaults for the CRS. If a custom extent or base resolution 

125 is given and snapping is not disabled, the grid is snapped to the default grid of the CRS. 

126 

127 Args: 

128 opts: Grid options. 

129 

130 Returns: 

131 A new grid. 

132 """ 

133 

134 mg = gws.MapGrid() 

135 mg.crs = opts.crs 

136 mg.extent = opts.extent or (gws.lib.crs.WGS84.extent if mg.crs.isGeographic else gws.lib.crs.WEBMERCATOR_SQUARE) 

137 mg.tileSize = opts.tileSize or DEFAULT_TILE_SIZE 

138 mg.baseResolution = opts.baseResolution or (mg.extent[3] - mg.extent[1]) / mg.tileSize 

139 with_snap = True if opts.withSnap is None else opts.withSnap 

140 if with_snap and (opts.extent or opts.baseResolution): 

141 _snap(mg, bool(opts.baseResolution)) 

142 return mg 

143 

144 

145def _snap(mg: gws.MapGrid, has_base_resolution: bool): 

146 """Snap the base resolution and the extent of a grid to the default grid of its CRS, in place.""" 

147 

148 ref = for_crs(mg.crs) 

149 

150 if has_base_resolution: 

151 k = round(math.log2(ref.baseResolution / mg.baseResolution)) 

152 else: 

153 w = mg.extent[2] - mg.extent[0] 

154 h = mg.extent[3] - mg.extent[1] 

155 k = math.floor(math.log2(ref.baseResolution * mg.tileSize / max(w, h)) + 1e-9) 

156 

157 mg.baseResolution = resolution_for_level(ref, max(k, 0)) 

158 

159 span = mg.baseResolution * mg.tileSize 

160 eps = span * 1e-6 

161 ox = ref.extent[0] 

162 oy = ref.extent[3] 

163 

164 mg.extent = ( 

165 ox + math.floor((mg.extent[0] - ox + eps) / span) * span, 

166 oy - math.ceil((oy - mg.extent[1] - eps) / span) * span, 

167 ox + math.ceil((mg.extent[2] - ox - eps) / span) * span, 

168 oy - math.floor((oy - mg.extent[3] + eps) / span) * span, 

169 ) 

170 

171 

172def resolution_for_level(mg: gws.MapGrid, z: int) -> float: 

173 """Return the resolution of a level. 

174 

175 Args: 

176 mg: A grid. 

177 z: Level. 

178 

179 Returns: 

180 ``baseResolution / 2**z``. 

181 """ 

182 

183 return mg.baseResolution / (1 << z) 

184 

185 

186def level_for_resolution(mg: gws.MapGrid, resolution: float) -> int: 

187 """Return the coarsest level whose resolution does not exceed the given one. 

188 

189 A level whose resolution is within ``RESOLUTION_TOLERANCE`` of the given one also matches. 

190 

191 Args: 

192 mg: A grid. 

193 resolution: Resolution in CRS units per pixel. 

194 

195 Returns: 

196 Level number. 

197 

198 Raises: 

199 ``ValueError``: If the resolution is not positive or no level up to 99 matches. 

200 """ 

201 

202 if resolution <= 0: 

203 raise ValueError(f'invalid resolution {resolution!r}') 

204 for z in range(100): 

205 r = resolution_for_level(mg, z) 

206 if r <= resolution or math.isclose(r, resolution, rel_tol=RESOLUTION_TOLERANCE): 

207 return z 

208 raise ValueError(f'invalid resolution {resolution!r}') 

209 

210 

211def props_for_resolutions(mg: gws.MapGrid, resolutions: list[float]) -> Props: 

212 """Create client props for a grid. 

213 

214 Args: 

215 mg: A grid. 

216 resolutions: Resolutions the client uses. The finest one determines the finest level. 

217 

218 Returns: 

219 Grid props with the resolutions of levels ``0`` up to the finest level needed. 

220 """ 

221 

222 zmax = level_for_resolution(mg, min(resolutions)) 

223 return Props( 

224 origin=gws.Origin.nw, 

225 extent=mg.extent, 

226 resolutions=[resolution_for_level(mg, z) for z in range(zmax + 1)], 

227 tileSize=mg.tileSize, 

228 ) 

229 

230 

231def tile_count_for_level(mg: gws.MapGrid, z: int) -> tuple[int, int]: 

232 """Return the number of tiles a level has across the frame. 

233 

234 Args: 

235 mg: A grid. 

236 z: Level. 

237 

238 Returns: 

239 Number of columns and rows, at least 1 each. 

240 """ 

241 

242 span = resolution_for_level(mg, z) * mg.tileSize 

243 return ( 

244 max(1, math.ceil((mg.extent[2] - mg.extent[0]) / span - 1e-6)), 

245 max(1, math.ceil((mg.extent[3] - mg.extent[1]) / span - 1e-6)), 

246 ) 

247 

248 

249def range_for_extent(mg: gws.MapGrid, extent: gws.Extent, z: int) -> gws.MapTileRange | None: 

250 """Return the range of tiles covering an extent at a level. 

251 

252 The range is clipped to the grid frame. 

253 

254 Args: 

255 mg: A grid. 

256 extent: Extent in the grid CRS. 

257 z: Level. 

258 

259 Returns: 

260 A tile range, or ``None`` if the extent does not intersect the frame. 

261 """ 

262 

263 span = resolution_for_level(mg, z) * mg.tileSize 

264 nx, ny = tile_count_for_level(mg, z) 

265 eps = span * 1e-6 

266 

267 x0 = math.floor((extent[0] - mg.extent[0] + eps) / span) 

268 x1 = math.floor((extent[2] - mg.extent[0] - eps) / span) 

269 y0 = math.floor((mg.extent[3] - extent[3] + eps) / span) 

270 y1 = math.floor((mg.extent[3] - extent[1] - eps) / span) 

271 

272 if x1 < x0 or y1 < y0 or x1 < 0 or y1 < 0 or x0 >= nx or y0 >= ny: 

273 return None 

274 return max(x0, 0), max(y0, 0), min(x1, nx - 1), min(y1, ny - 1), z 

275 

276 

277def intersect_ranges(a: gws.MapTileRange, b: gws.MapTileRange) -> gws.MapTileRange | None: 

278 """Return the intersection of two tile ranges of the same level. 

279 

280 Args: 

281 a: First tile range. 

282 b: Second tile range. 

283 

284 Returns: 

285 The intersection, or ``None`` if the ranges do not intersect. 

286 

287 Raises: 

288 ``ValueError``: If the ranges are at different levels. 

289 """ 

290 

291 if a[4] != b[4]: 

292 raise ValueError(f'cannot intersect ranges of different levels: {a!r}, {b!r}') 

293 x0, y0, x1, y1 = max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3]) 

294 if x1 < x0 or y1 < y0: 

295 return None 

296 return x0, y0, x1, y1, a[4] 

297 

298 

299def extent_for_range(mg: gws.MapGrid, tr: gws.MapTileRange) -> gws.Extent: 

300 """Return the extent of a tile range. 

301 

302 Args: 

303 mg: A grid. 

304 tr: Tile range. 

305 

306 Returns: 

307 Extent in the grid CRS. 

308 """ 

309 

310 x0, y0, x1, y1, z = tr 

311 span = resolution_for_level(mg, z) * mg.tileSize 

312 return ( 

313 mg.extent[0] + x0 * span, 

314 mg.extent[3] - (y1 + 1) * span, 

315 mg.extent[0] + (x1 + 1) * span, 

316 mg.extent[3] - y0 * span, 

317 ) 

318 

319 

320def extent_for_tile(mg: gws.MapGrid, tile: gws.MapTile) -> gws.Extent: 

321 """Return the extent of a tile. 

322 

323 Args: 

324 mg: A grid. 

325 tile: Tile. 

326 

327 Returns: 

328 Extent in the grid CRS. 

329 """ 

330 

331 x, y, z = tile 

332 return extent_for_range(mg, (x, y, x, y, z)) 

333 

334 

335def matrix_set_for_grid(mg: gws.MapGrid, max_level: int, identifier: str = '') -> gws.TileMatrixSet: 

336 """Express the levels ``0..max_level`` of a grid as a tile matrix set. 

337 

338 Matrix identifiers are the level numbers as strings. ``scale`` is not set. 

339 

340 Args: 

341 mg: A grid. 

342 max_level: Finest level to include. 

343 identifier: Identifier of the matrix set. 

344 

345 Returns: 

346 A tile matrix set. 

347 """ 

348 

349 tms = gws.TileMatrixSet(identifier=identifier, crs=mg.crs, matrices=[]) 

350 for z in range(max_level + 1): 

351 nx, ny = tile_count_for_level(mg, z) 

352 tms.matrices.append( 

353 gws.TileMatrix( 

354 identifier=str(z), 

355 resolution=resolution_for_level(mg, z), 

356 x=mg.extent[0], 

357 y=mg.extent[3], 

358 width=nx, 

359 height=ny, 

360 tileWidth=mg.tileSize, 

361 tileHeight=mg.tileSize, 

362 extent=mg.extent, 

363 ) 

364 ) 

365 return tms 

366 

367 

368def matrix_for_resolution(tms: gws.TileMatrixSet, resolution: float) -> gws.TileMatrix: 

369 """Return the coarsest matrix that does not need upscaling. 

370 

371 A matrix whose resolution is within ``RESOLUTION_TOLERANCE`` of the given one also matches. 

372 

373 Args: 

374 tms: Tile matrix set. 

375 resolution: Wanted resolution in CRS units per pixel. 

376 

377 Returns: 

378 The matching matrix, or the finest one if all are coarser. 

379 """ 

380 

381 # Coarsest matrix with tm.resolution <= resolution, i.e. never upscale (downscale up to 2x). 

382 # Cross-CRS the wanted resolution rarely hits the source ladder, e.g. 3857 -> 25832 

383 # at 51N needs 1.6x the target resolution, landing between two levels. 

384 # Alternatives: nearest by ratio (upscale up to sqrt(2), coarser cartography, bigger labels) 

385 # or a threshold as in MapProxy (allow upscale below a factor, default 1.15). 

386 coarsest_first = sorted(tms.matrices, key=lambda m: -m.resolution) 

387 for tm in coarsest_first: 

388 if tm.resolution <= resolution or math.isclose(tm.resolution, resolution, rel_tol=RESOLUTION_TOLERANCE): 

389 return tm 

390 return coarsest_first[-1] 

391 

392 

393def matrix_range_for_extent(tm: gws.TileMatrix, extent: gws.Extent) -> gws.MapTileRange | None: 

394 """Return the range of matrix tiles covering an extent. 

395 

396 The range is clipped to the matrix. 

397 

398 Args: 

399 tm: Tile matrix. 

400 extent: Extent in the matrix CRS. 

401 

402 Returns: 

403 A tile range with ``z`` set to 0, or ``None`` if the extent does not intersect the matrix. 

404 """ 

405 

406 tile_w = tm.resolution * tm.tileWidth 

407 tile_h = tm.resolution * tm.tileHeight 

408 

409 # nudge the edges inward by a fraction of a tile, so that an edge lying exactly 

410 # on a tile boundary does not pull in a neighbouring tile through float noise 

411 eps = 1e-6 

412 

413 x0 = math.floor((extent[0] - tm.x + tile_w * eps) / tile_w) 

414 x1 = math.floor((extent[2] - tm.x - tile_w * eps) / tile_w) 

415 y0 = math.floor((tm.y - extent[3] + tile_h * eps) / tile_h) 

416 y1 = math.floor((tm.y - extent[1] - tile_h * eps) / tile_h) 

417 

418 if x1 < x0 or y1 < y0 or x1 < 0 or y1 < 0 or x0 >= tm.width or y0 >= tm.height: 

419 return None 

420 return max(x0, 0), max(y0, 0), min(x1, int(tm.width) - 1), min(y1, int(tm.height) - 1), 0 

421 

422 

423def matrix_extent_for_range(tm: gws.TileMatrix, tr: gws.MapTileRange) -> gws.Extent: 

424 """Return the extent of a range of matrix tiles. 

425 

426 Args: 

427 tm: Tile matrix. 

428 tr: Tile range; ``z`` is ignored. 

429 

430 Returns: 

431 Extent in the matrix CRS. 

432 """ 

433 

434 tile_w = tm.resolution * tm.tileWidth 

435 tile_h = tm.resolution * tm.tileHeight 

436 x0, y0, x1, y1, _ = tr 

437 return ( 

438 tm.x + x0 * tile_w, 

439 tm.y - (y1 + 1) * tile_h, 

440 tm.x + (x1 + 1) * tile_w, 

441 tm.y - y0 * tile_h, 

442 ) 

443 

444 

445def enum_tiles(tr: gws.MapTileRange) -> Iterator[gws.MapTile]: 

446 """Enumerate the tiles of a range, row by row. 

447 

448 Args: 

449 tr: Tile range. 

450 

451 Yields: 

452 Tiles ``(x, y, z)``. 

453 """ 

454 

455 x0, y0, x1, y1, z = tr 

456 for y in range(y0, y1 + 1): 

457 for x in range(x0, x1 + 1): 

458 yield x, y, z 

459 

460 

461def in_range(mt: gws.MapTile, tr: gws.MapTileRange) -> bool: 

462 """Check if a tile lies within a range. 

463 

464 Args: 

465 mt: Tile. 

466 tr: Tile range. 

467 

468 Returns: 

469 ``True`` if the tile is at the level of the range and inside it. 

470 """ 

471 

472 x, y, z = mt 

473 return z == tr[4] and tr[0] <= x <= tr[2] and tr[1] <= y <= tr[3]