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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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