Validating a WMTS Tile Matrix Set Against Its Capabilities

A tile matrix set is a set of numbers that must agree with each other. Walk the levels, assert that matrix dimensions double and scale denominators halve within a relative tolerance of about 1e-4, that ground resolution equals the scale denominator times 0.00028 metres, and that the top-left corner is written in the reference system’s own axis order. Anything hand-edited fails at least one of these.

The Core Challenge: Seven Numbers Per Level, All Interlocking

Each TileMatrix in a WMTS capabilities document publishes an identifier, a scale denominator, a top-left corner, a tile width and height, and a matrix width and height. Those seven values are not independent — the scale denominator determines the ground resolution, which with the tile size and matrix dimensions determines the extent covered, which must match the reference system.

Five invariants a tile matrix set must satisfy A five-row grid of tile matrix set invariants. Matrix width and height must double between consecutive levels; the scale denominator must halve; the ground resolution must equal the scale denominator times the OGC standard pixel size of 0.28 millimetres; the top-left corner must be written in the axis order of the declared reference system; and the matrix must be wide enough to cover the reference system's extent. Invariant Must satisfy Symptom when it does not Matrix growth width and height double per level tiles drift with zoom Scale progression denominator halves per level wrong level requested Pixel span scale x 0.00028 = ground metres scale bar lies Origin TopLeftCorner in the CRS axis order whole grid offset Coverage width x tile x span >= CRS extent edge tiles missing

Because the values are published rather than derived, a service can advertise a set that is internally inconsistent, and nothing rejects it. The tiles then render, at slightly the wrong place, in a way that gets worse with zoom. Clients that hard-code a well-known scale progression — which most web mapping libraries do — silently request the wrong level, which is the failure the configuration guide warns about from the publishing side.

Production-Ready Code

from __future__ import annotations

from dataclasses import dataclass
from xml.etree import ElementTree as ET

import requests
from pyproj import CRS

NS = {"wmts": "http://www.opengis.net/wmts/1.0",
      "ows": "http://www.opengis.net/ows/1.1"}

# The OGC standard rendering pixel size. Ground resolution in CRS units is
# always scale_denominator * this value.
STANDARD_PIXEL_M = 0.00028
TOLERANCE = 1e-4

@dataclass(frozen=True)
class Level:
    identifier: str
    scale_denominator: float
    top_left: tuple[float, float]
    tile_width: int
    tile_height: int
    matrix_width: int
    matrix_height: int

    def resolution(self, metres_per_unit: float) -> float:
        return self.scale_denominator * STANDARD_PIXEL_M / metres_per_unit

    def span(self, metres_per_unit: float) -> tuple[float, float]:
        res = self.resolution(metres_per_unit)
        return (self.matrix_width * self.tile_width * res,
                self.matrix_height * self.tile_height * res)

@dataclass
class Finding:
    level: str
    invariant: str
    detail: str

    def __str__(self) -> str:
        return f"{self.level:>16}  {self.invariant:<18} {self.detail}"

def parse_levels(capabilities: bytes, set_id: str) -> tuple[str, list[Level]]:
    root = ET.fromstring(capabilities)
    for tms in root.iter(f"{{{NS['wmts']}}}TileMatrixSet"):
        if tms.findtext("ows:Identifier", "", NS) != set_id:
            continue
        srs = tms.findtext("ows:SupportedCRS", "", NS)
        levels = []
        for m in tms.findall("wmts:TileMatrix", NS):
            corner = [float(v) for v in
                      (m.findtext("wmts:TopLeftCorner", "", NS)).split()]
            levels.append(Level(
                identifier=m.findtext("ows:Identifier", "", NS),
                scale_denominator=float(m.findtext("wmts:ScaleDenominator", "0", NS)),
                top_left=(corner[0], corner[1]),
                tile_width=int(m.findtext("wmts:TileWidth", "256", NS)),
                tile_height=int(m.findtext("wmts:TileHeight", "256", NS)),
                matrix_width=int(m.findtext("wmts:MatrixWidth", "0", NS)),
                matrix_height=int(m.findtext("wmts:MatrixHeight", "0", NS))))
        return srs, levels
    raise KeyError(f"tile matrix set {set_id!r} is not advertised")

def _close(a: float, b: float, tolerance: float = TOLERANCE) -> bool:
    return abs(a - b) <= tolerance * max(abs(a), abs(b), 1.0)

def validate(srs: str, levels: list[Level]) -> list[Finding]:
    findings: list[Finding] = []
    crs = CRS.from_user_input(srs)
    metres_per_unit = 1.0 if crs.axis_info[0].unit_name.startswith("met") else 111319.49
    lat_first = crs.axis_info[0].direction.lower() in {"north", "south"}
    area = crs.area_of_use

    for previous, level in zip(levels, levels[1:]):
        if not _close(previous.scale_denominator / 2, level.scale_denominator):
            findings.append(Finding(
                level.identifier, "scale progression",
                f"expected {previous.scale_denominator / 2:.6f}, "
                f"published {level.scale_denominator:.6f}"))
        for axis, prev_n, this_n in (("width", previous.matrix_width, level.matrix_width),
                                     ("height", previous.matrix_height, level.matrix_height)):
            if this_n != prev_n * 2:
                findings.append(Finding(
                    level.identifier, f"matrix {axis}",
                    f"expected {prev_n * 2}, published {this_n}"))
        if level.top_left != previous.top_left:
            findings.append(Finding(
                level.identifier, "origin",
                f"moved from {previous.top_left} to {level.top_left}"))

    # Geometry checks against the reference system's own extent.
    if levels and area is not None:
        first = levels[0]
        span_x, span_y = first.span(metres_per_unit)
        expected_x = abs(area.east - area.west)
        expected_y = abs(area.north - area.south)
        if lat_first and first.top_left[0] < first.top_left[1]:
            findings.append(Finding(
                first.identifier, "origin axis order",
                f"{srs} is latitude-first but TopLeftCorner reads "
                f"{first.top_left} — expected northing first"))
        if span_x + TOLERANCE < expected_x:
            findings.append(Finding(
                first.identifier, "coverage",
                f"level 0 spans {span_x:.4f} but the CRS extent is {expected_x:.4f}"))
    return findings

def validate_url(capabilities_url: str, set_id: str) -> list[Finding]:
    resp = requests.get(capabilities_url, timeout=60, params={
        "SERVICE": "WMTS", "VERSION": "1.0.0", "REQUEST": "GetCapabilities"})
    resp.raise_for_status()
    srs, levels = parse_levels(resp.content, set_id)
    return validate(srs, levels)

Step-by-Step Walkthrough

Compare neighbours, not absolutes. Checking each level against a hard-coded reference progression only works for the well-known sets. Comparing each level against its predecessor validates any set, including legitimate custom ones, and localises a defect to the level where the ratio breaks rather than reporting every level after it.

A validator walks levels and compares neighbours A five-stage validation chain. The capabilities document is fetched once; the seven values that define each tile matrix level are read; each level's scale and matrix dimensions are compared against the previous level within a relative tolerance; the origin and total coverage are checked against the reference system extent; and every violation is reported with its level, the invariant broken and the numeric discrepancy. Fetch capabilities one document Per level read the seven values Check the ratios against the previous level Check the geometry origin and coverage Report level, invariant, delta XML parse 1e-4 tolerance CRS extent

The tolerance is relative and deliberately loose. Published scale denominators are typically truncated to six decimal places, so an exact halving comparison fails on almost every real service. A relative tolerance of 1e-4 accepts that rounding and still catches a level that is out by a part in a thousand, which is enough to misplace tiles visibly.

0.00028 metres is not a magic number. It is the standardised rendering pixel size the OGC uses to relate a scale denominator to a ground resolution, and it is why the same scale denominator means the same ground resolution across every conformant service. A set whose resolution does not match this relation is one where the publisher computed scales from a different assumed screen DPI.

The origin must follow the reference system’s axis order. For a geographic system defined latitude-first, TopLeftCorner reads 90 -180, not -180 90. Deriving that expectation from the registry definition rather than from a list of codes means the check is correct for grids in systems nobody anticipated.

Verification

Run it against a service and read the findings:

python validate_tms.py https://example.org/wmts WebMercatorQuad

A conformant set prints nothing. A hand-edited one prints exactly where it broke:

    EPSG:3857:11  scale progression  expected 272989.386752, published 272989.400000
    EPSG:3857:14  matrix width       expected 16384, published 16383
    EPSG:3857:14  matrix height      expected 16384, published 16383

The first line is rounding at the edge of the tolerance and is harmless. The second and third are real: a matrix one tile short on each axis leaves the eastern and southern edges of the world unaddressable at that zoom, which shows as missing tiles along two edges and nowhere else.

Gotchas & Edge Cases

A ratio other than two is legal. WMTS does not require a quad pyramid. Sets with a ratio of three, or with an irregular progression drawn from a national mapping series, are conformant and will fail a doubling check. Where a service publishes such a set deliberately, the validator should assert the ratio is consistent rather than that it equals two.

Not every deviation is a bug, and the pattern says which A decision diamond for scale denominators that do not halve exactly. A relative error below one part in ten thousand is published rounding and is harmless; an error that grows with zoom indicates values computed by repeated multiplication rather than from level zero; a single level out of step is a hand edit; and a consistent ratio other than two is a legitimate custom scale set rather than a defect. The scale denominators do not halve exactly. Is that a defect? Rounding harmless — published values are truncated Accumulated drift recompute from level zero A hand edit regenerate the whole set A custom set valid, but not a quad pyramid relative error < 1e-4 error grows with zoom one level is off ratio is not 2

MatrixHeight need not equal MatrixWidth. For a geographic tile matrix set covering the whole globe, the world is twice as wide as it is tall, so level zero is commonly two tiles by one. Assuming a square level zero is a frequent source of false findings.

TileMatrixSetLimits are a different thing. They live on the layer’s TileMatrixSetLink, not on the set, and they narrow which tiles a particular layer holds. A validator that conflates the two reports a set as under-covering when it is the layer that is limited.

Validate at startup, not on the first user report. A tile matrix set is static configuration, so running this check when a service starts — or in the pipeline described in CI/CD and Compliance Testing for Spatial Services — costs one request and catches a class of defect that is otherwise found visually, months later.

Frequently Asked Questions

What tolerance should I use for the scale progression?

A relative tolerance around one part in ten thousand. Published denominators are truncated, so an exact comparison produces false findings on almost every service, while a looser tolerance than 1e-3 starts to admit errors large enough to misplace tiles by a visible fraction of a tile at high zoom.

Is a set with a ratio other than 2 broken?

No. WMTS permits any scale progression, and national mapping agencies frequently publish sets derived from their traditional paper map scales. What matters is that the ratio is consistent and that clients read the published denominators rather than assuming a doubling — which is exactly why hard-coded progressions in web mapping libraries cause trouble against such services.

Why does the origin have to be identical on every level?

Because the tile addressing scheme is defined relative to it. If the origin moves between levels, tiles at different zooms describe different anchors and a client zooming in sees the map jump. A published set with a per-level origin is either a generation bug or a set that cannot be used as a pyramid.

Can I validate without pyproj?

The scale and matrix ratios, yes — those are pure arithmetic on the published numbers. The axis order and coverage checks need to know what the declared reference system actually is, and pyproj is the practical way to ask. Skipping them leaves the two geometry defects undetected, which are precisely the ones that offset an entire grid.


Back to WMTS Tile Matrix Sets Explained

Related