How to Add Time Dimension Support to a WMS Layer

A time-enabled WMS layer publishes a <Dimension name="time" units="ISO8601"> element carrying an extent, and clients select a slice with the TIME query parameter. The two hazards are that omitting TIME silently applies the server’s declared default, and that nearestValue="1" silently substitutes the closest available instant — so a client that neither sends the parameter nor reads the response headers can render the wrong week and never know.

The Core Challenge: The Default Is Not ‘Now’

WMS dimensions are a general mechanism — TIME and ELEVATION are the two the specification names, but any dimension may be declared. What makes time specific is that almost every client assumes a sensible default, and the specification provides none. The default attribute on the Dimension element is whatever the publisher configured, which for many services is the first granule ever loaded rather than the most recent.

A dimension is a contract in two halves Two panels. The left shows the Dimension element a WMS publishes in its capabilities document, carrying the units, a default value, the nearest-value policy and the extent expressed as an ISO 8601 start, end and period. The right shows the forms a client may send in the TIME parameter of a GetMap request: an instant, an interval, the keyword current, or nothing at all, in which case the declared default applies. Capabilities declares it <Dimension name='time' units='ISO8601' default='2024-06-01' nearestValue='0'> 2024-01-01/2024-12-31/P1D GetMap requests it &TIME=2024-06-01 &TIME=2024-06-01/2024-06-07 &TIME=current (omitted -> the default) A client that never sends TIME still gets the default — silently, and possibly the wrong slice.

The declaration and the request are two halves of one contract, and both halves are frequently misread. The extent syntax start/end/period describes a regular series — 2024-01-01/2024-12-31/P1D means daily granules through the year — but a service may equally publish an explicit comma-separated list of instants, and a client that parses only one form fails against the other. The capabilities parser has to handle both.

Production-Ready Code

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from xml.etree import ElementTree as ET

import requests

WMS = {"wms": "http://www.opengis.net/wms"}
ISO = "%Y-%m-%dT%H:%M:%SZ"

@dataclass
class TimeExtent:
    instants: list[datetime] | None      # explicit list form
    start: datetime | None               # start/end/period form
    end: datetime | None
    period: timedelta | None
    default: datetime | None
    nearest_value: bool

    def contains(self, when: datetime) -> bool:
        if self.instants is not None:
            return when in self.instants
        if self.start and self.end:
            if not (self.start <= when <= self.end):
                return False
            if not self.period:
                return True
            offset = (when - self.start).total_seconds()
            return abs(offset % self.period.total_seconds()) < 1e-6
        return False

def _parse_period(token: str) -> timedelta:
    """Handle the ISO 8601 durations that appear in real WMS extents."""
    if not token.startswith("P"):
        raise ValueError(f"not an ISO 8601 duration: {token!r}")
    days, seconds, body = 0, 0, token[1:]
    date_part, _, time_part = body.partition("T")
    for value, unit in _tokens(date_part):
        days += value * {"D": 1, "W": 7, "M": 30, "Y": 365}[unit]
    for value, unit in _tokens(time_part):
        seconds += value * {"S": 1, "M": 60, "H": 3600}[unit]
    return timedelta(days=days, seconds=seconds)

def _tokens(part: str):
    number = ""
    for ch in part:
        if ch.isdigit():
            number += ch
        elif number:
            yield int(number), ch
            number = ""

def read_time_extent(capabilities: bytes, layer_name: str) -> TimeExtent | None:
    """Extract the time dimension of a named layer, in either extent form."""
    root = ET.fromstring(capabilities)
    for layer in root.iter(f"{{{WMS['wms']}}}Layer"):
        name = layer.find("wms:Name", WMS)
        if name is None or name.text != layer_name:
            continue
        for dim in layer.findall("wms:Dimension", WMS):
            if (dim.get("name") or "").lower() != "time":
                continue
            raw = (dim.text or "").strip()
            default = dim.get("default")
            nearest = dim.get("nearestValue") == "1"
            if "/" in raw:
                start, end, *rest = raw.split("/")
                return TimeExtent(
                    None, _dt(start), _dt(end),
                    _parse_period(rest[0]) if rest else None,
                    _dt(default) if default else None, nearest)
            return TimeExtent(
                [_dt(v.strip()) for v in raw.split(",") if v.strip()],
                None, None, None,
                _dt(default) if default else None, nearest)
    return None

def _dt(token: str) -> datetime:
    token = token.strip().rstrip("Z") + "Z"
    return datetime.strptime(token, ISO).replace(tzinfo=timezone.utc)

def get_map_at(base: str, layer: str, when: datetime, bbox: str,
               extent: TimeExtent) -> bytes:
    """Fetch one time slice, refusing instants the extent does not contain.

    Validating locally turns a silent nearest-value substitution into an
    explicit decision made by the caller.
    """
    if not extent.contains(when):
        raise ValueError(
            f"{when.strftime(ISO)} is not in the layer's advertised extent; "
            f"nearestValue={'on' if extent.nearest_value else 'off'}")
    resp = requests.get(base, timeout=60, params={
        "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
        "LAYERS": layer, "STYLES": "", "CRS": "EPSG:3857", "BBOX": bbox,
        "WIDTH": "512", "HEIGHT": "512", "FORMAT": "image/png",
        "TRANSPARENT": "true", "TIME": when.strftime(ISO),
    })
    resp.raise_for_status()
    if resp.headers.get("content-type", "").startswith("text/xml"):
        raise RuntimeError(resp.text[:400])
    return resp.content

Step-by-Step Walkthrough

Both extent forms have to be parsed. A service publishing a regular series writes start/end/period; one publishing irregular granules — satellite passes, survey dates — writes a comma-separated list that can run to thousands of entries. read_time_extent branches on the presence of a slash, which is the only reliable discriminator, and models the two forms as separate fields rather than trying to normalise an irregular list into a period.

Five outcomes for one TIME parameter A five-row grid of what a WMS does with a TIME value. An exact match returns that slice; with nearest-value snapping enabled a near miss silently returns a different instant; with snapping disabled the same request raises InvalidDimensionValue; an interval is composited into a single image rather than returning several; and omitting the parameter applies whatever default the service declared. Requested instant Server behaviour Client should Exact match exists returns that slice send the instant No match, nearestValue=1 snaps to the closest check the returned time header No match, nearestValue=0 InvalidDimensionValue validate against the extent first Interval requested composites the range expect one image, not many TIME omitted applies the default never rely on it being current

contains is what makes the client honest. With nearestValue="1" a server answers a request for an instant it does not have by returning the closest one it does, with no error and, on many implementations, no header saying what it substituted. Validating the requested instant against the advertised extent before sending turns that into a local exception naming the problem. Where snapping is genuinely wanted, the caller can catch the error and pick a neighbour deliberately.

Interval requests composite, they do not enumerate. TIME=2024-06-01/2024-06-07 returns a single image in which the whole week has been composited according to whatever rule the server applies — usually last-value-wins. Clients expecting seven images get one, and clients expecting a mosaic get whichever granule happened to be drawn last. If you want the individual slices, request them individually.

Duration parsing is small but not trivial. P1D, PT6H and P1M all appear in production extents, and the month case is genuinely ambiguous — thirty days is an approximation that will drift. Where months matter, treat the extent as irregular and enumerate the instants rather than computing them.

Verification

Read the extent, then confirm the service honours an explicit instant:

caps = requests.get(base, params={"SERVICE": "WMS", "REQUEST": "GetCapabilities"}).content
extent = read_time_extent(caps, "radar:precipitation")
print(extent.start, extent.end, extent.period, "default:", extent.default)
png = get_map_at(base, "radar:precipitation", extent.end, bbox, extent)
open("slice.png", "wb").write(png)
2024-01-01 00:00:00+00:00 2024-12-31 00:00:00+00:00 1 day, 0:00:00 default: 2024-01-01 00:00:00+00:00

Note the default in that output: it is the first granule, not the last. Any client that omitted TIME here has been rendering January all year.

Gotchas & Edge Cases

Capabilities documents for time-enabled layers get large and stale. A layer with five years of daily granules published as an explicit list produces a capabilities document in the tens of megabytes, which clients cache aggressively — and a cached extent does not include granules loaded since. Re-reading capabilities before a long run, or preferring the period form, both help.

Wrong slice, right pixels — three ways it happens A decision diamond for a time-enabled WMS layer returning an unexpected slice. Omitting the TIME parameter applies the declared default rather than the present moment; nearest-value snapping silently substitutes the closest available instant; a cached capabilities document advertises an extent that no longer matches the data; and when all three are ruled out there is genuinely no granule at the requested time. A time-enabled layer returns the wrong slice. Which link broke? Send it explicitly the default is not 'now' Read the response header nearestValue hid the miss Re-read capabilities the extent is cached per layer The data is missing no granule at that instant no TIME sent snapped silently extent is stale all correct

TIME=current is a vendor extension. It is widely implemented and not in the specification, so a client relying on it works against GeoServer and fails elsewhere. Reading the extent and requesting the last instant explicitly is portable and tells you what you actually got.

Time and elevation multiply. A layer with both dimensions requires both parameters, and omitting either applies that dimension’s default. The failure looks identical to the time case and is diagnosed the same way.

Caching a time-enabled layer needs the dimension in the key. A tile cache keyed only on layer, extent and zoom will serve June’s image for a December request. The cache key must include every dimension value, which is one reason time-enabled layers are usually excluded from the seeding strategy in Pre-Seeding a WMTS Tile Cache With gdal2tiles.

Frequently Asked Questions

What happens if I omit TIME on a time-enabled layer?

The service applies the default declared in its Dimension element, and returns a perfectly valid image of that instant. Nothing in the response indicates that a default was applied. Since the default is frequently the earliest granule rather than the latest, a client that never sends the parameter can render years-old data indefinitely without any error surfacing.

Should I enable nearestValue on a service I publish?

Only if consumers genuinely want approximate matching, and only alongside a response header stating the instant actually served. Without that header, snapping converts a detectable error — InvalidDimensionValue — into an undetectable one, which is a poor trade for a data service.

How do I discover which instants actually have data?

From the extent in the capabilities document, which is the only thing the protocol offers. Where the extent is published in period form, the regular series it describes is a claim about the schedule rather than a guarantee that every granule loaded — so a request that returns a blank image at a valid instant usually means a gap in ingestion, not a protocol problem.

Can I request several time slices in one call?

You can send an interval, but the response is a single composited image rather than a series. There is no WMS operation that returns multiple images. Fetching slices individually — concurrently, with a bounded pool — is the workable pattern, and it also lets you handle a per-slice failure without losing the batch.


Back to Understanding OGC Web Map Service Specifications

Related