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

144 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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( 

46 gws.lib.grid.Options( 

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

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

49 tileSize=512, 

50 ) 

51 ) 

52""" 

53 

54import math 

55from typing import Optional 

56from collections.abc import Iterator 

57 

58import gws 

59import gws.lib.crs 

60 

61RESOLUTION_TOLERANCE = 0.01 

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

63 

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

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

66""" 

67 

68UPSCALE_BIAS = 2 

69"""Preference for upscaling a source matrix over downscaling one, as a factor on a log scale. 

70 

711 picks the matrix with the smaller scale factor, values above 1 nudge towards upscaling the 

72coarser matrix, values below 1 towards downscaling the finer one. Downscaling makes labels and 

73thin lines unreadable, upscaling only makes them soft. With a 2x ladder, a matrix is upscaled by 

74at most ``2 ** (UPSCALE_BIAS / (UPSCALE_BIAS + 1))``, about 1.59 for the default. 

75""" 

76 

77DEFAULT_TILE_SIZE = 256 

78"""Default tile size in pixels.""" 

79 

80 

81class Props(gws.Props): 

82 origin: str 

83 extent: gws.Extent 

84 resolutions: list[float] 

85 tileSize: int 

86 

87 

88class Config(gws.Config): 

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

90 

91 crs: Optional[gws.CrsName] 

92 """Grid CRS.""" 

93 extent: Optional[gws.Extent] 

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

95 baseResolution: Optional[float] 

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

97 tileSize: Optional[int] 

98 """Tile size in pixels.""" 

99 withSnap: bool = True 

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

101 

102 

103class Options(gws.Data): 

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

105 

106 crs: gws.Crs 

107 """Grid CRS.""" 

108 extent: Optional[gws.Extent] 

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

110 baseResolution: Optional[float] 

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

112 tileSize: Optional[int] 

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

114 withSnap: Optional[bool] 

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

116 

117 

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

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

120 

121 Args: 

122 crs: Grid CRS. 

123 

124 Returns: 

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

126 """ 

127 

128 return new(Options(crs=crs)) 

129 

130 

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

132 """Create a grid. 

133 

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

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

136 

137 Args: 

138 opts: Grid options. 

139 

140 Returns: 

141 A new grid. 

142 """ 

143 

144 mg = gws.MapGrid() 

145 mg.crs = opts.crs 

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

147 mg.tileSize = opts.tileSize or DEFAULT_TILE_SIZE 

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

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

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

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

152 return mg 

153 

154 

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

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

157 

158 ref = for_crs(mg.crs) 

159 

160 if has_base_resolution: 

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

162 else: 

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

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

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

166 

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

168 

169 span = mg.baseResolution * mg.tileSize 

170 eps = span * 1e-6 

171 ox = ref.extent[0] 

172 oy = ref.extent[3] 

173 

174 mg.extent = ( 

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

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

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

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

179 ) 

180 

181 

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

183 """Return the resolution of a level. 

184 

185 Args: 

186 mg: A grid. 

187 z: Level. 

188 

189 Returns: 

190 ``baseResolution / 2**z``. 

191 """ 

192 

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

194 

195 

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

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

198 

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

200 

201 Args: 

202 mg: A grid. 

203 resolution: Resolution in CRS units per pixel. 

204 

205 Returns: 

206 Level number. 

207 

208 Raises: 

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

210 """ 

211 

212 if resolution <= 0: 

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

214 for z in range(100): 

215 r = resolution_for_level(mg, z) 

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

217 return z 

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

219 

220 

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

222 """Create client props for a grid. 

223 

224 Args: 

225 mg: A grid. 

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

227 

228 Returns: 

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

230 """ 

231 

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

233 return Props( 

234 origin=gws.Origin.nw, 

235 extent=mg.extent, 

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

237 tileSize=mg.tileSize, 

238 ) 

239 

240 

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

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

243 

244 Args: 

245 mg: A grid. 

246 z: Level. 

247 

248 Returns: 

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

250 """ 

251 

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

253 return ( 

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

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

256 ) 

257 

258 

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

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

261 

262 The range is clipped to the grid frame. 

263 

264 Args: 

265 mg: A grid. 

266 extent: Extent in the grid CRS. 

267 z: Level. 

268 

269 Returns: 

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

271 """ 

272 

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

274 nx, ny = tile_count_for_level(mg, z) 

275 eps = span * 1e-6 

276 

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

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

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

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

281 

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

283 return None 

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

285 

286 

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

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

289 

290 Args: 

291 a: First tile range. 

292 b: Second tile range. 

293 

294 Returns: 

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

296 

297 Raises: 

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

299 """ 

300 

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

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

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

304 if x1 < x0 or y1 < y0: 

305 return None 

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

307 

308 

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

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

311 

312 Args: 

313 mg: A grid. 

314 tr: Tile range. 

315 

316 Returns: 

317 Extent in the grid CRS. 

318 """ 

319 

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

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

322 return ( 

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

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

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

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

327 ) 

328 

329 

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

331 """Return the extent of a tile. 

332 

333 Args: 

334 mg: A grid. 

335 tile: Tile. 

336 

337 Returns: 

338 Extent in the grid CRS. 

339 """ 

340 

341 x, y, z = tile 

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

343 

344 

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

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

347 

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

349 

350 Args: 

351 mg: A grid. 

352 max_level: Finest level to include. 

353 identifier: Identifier of the matrix set. 

354 

355 Returns: 

356 A tile matrix set. 

357 """ 

358 

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

360 for z in range(max_level + 1): 

361 nx, ny = tile_count_for_level(mg, z) 

362 tms.matrices.append( 

363 gws.TileMatrix( 

364 identifier=str(z), 

365 resolution=resolution_for_level(mg, z), 

366 x=mg.extent[0], 

367 y=mg.extent[3], 

368 width=nx, 

369 height=ny, 

370 tileWidth=mg.tileSize, 

371 tileHeight=mg.tileSize, 

372 extent=mg.extent, 

373 ) 

374 ) 

375 return tms 

376 

377 

378def best_resolution(resolution: float, resolutions: list[float]) -> float: 

379 """Return the resolution to render a resolution from. 

380 

381 Of the two resolutions around the given one, the coarser one needs upscaling and the 

382 finer one downscaling. The scale factors are compared on a log scale, weighted by ``UPSCALE_BIAS``. 

383 

384 Args: 

385 resolution: Wanted resolution. 

386 resolutions: Available resolutions, in any order. 

387 

388 Returns: 

389 The matching resolution, or the finest one if all are coarser. 

390 """ 

391 

392 by_res = sorted(resolutions, reverse=True) 

393 prev = None 

394 

395 for r in by_res: 

396 if math.isclose(r, resolution, rel_tol=RESOLUTION_TOLERANCE): 

397 return r 

398 if r > resolution: 

399 prev = r 

400 continue 

401 if prev is None: 

402 return r 

403 up_scale_factor = math.log(prev / resolution) 

404 down_scale_factor = math.log(resolution / r) 

405 if up_scale_factor <= UPSCALE_BIAS * down_scale_factor: 

406 return prev 

407 return r 

408 

409 return by_res[-1] 

410 

411 

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

413 """Return the matrix to render a resolution from, see ``best_resolution``. 

414 

415 Args: 

416 tms: Tile matrix set. 

417 resolution: Wanted resolution in CRS units per pixel. 

418 

419 Returns: 

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

421 """ 

422 

423 r = best_resolution(resolution, [tm.resolution for tm in tms.matrices]) 

424 for tm in tms.matrices: 

425 if tm.resolution == r: 

426 return tm 

427 raise ValueError(f'no matrix found for resolution {r}') 

428 

429 

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

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

432 

433 The range is clipped to the matrix. 

434 

435 Args: 

436 tm: Tile matrix. 

437 extent: Extent in the matrix CRS. 

438 

439 Returns: 

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

441 """ 

442 

443 tile_w = tm.resolution * tm.tileWidth 

444 tile_h = tm.resolution * tm.tileHeight 

445 

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

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

448 eps = 1e-6 

449 

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

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

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

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

454 

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

456 return None 

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

458 

459 

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

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

462 

463 Args: 

464 tm: Tile matrix. 

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

466 

467 Returns: 

468 Extent in the matrix CRS. 

469 """ 

470 

471 tile_w = tm.resolution * tm.tileWidth 

472 tile_h = tm.resolution * tm.tileHeight 

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

474 return ( 

475 tm.x + x0 * tile_w, 

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

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

478 tm.y - y0 * tile_h, 

479 ) 

480 

481 

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

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

484 

485 Args: 

486 tr: Tile range. 

487 

488 Yields: 

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

490 """ 

491 

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

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

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

495 yield x, y, z 

496 

497 

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

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

500 

501 Args: 

502 mt: Tile. 

503 tr: Tile range. 

504 

505 Returns: 

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

507 """ 

508 

509 x, y, z = mt 

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