OGC API-EDR
Most questions asked of an environmental dataset have a shape. What is it like here. What is it like inside this box. What is it like along this line, at the moment I reach each point on it. OGC API — Environmental Data Retrieval, always shortened to EDR, is a web standard built on the observation that a short list of shapes covers most of what anyone asks, and that a short list of shapes can therefore stand in for a query language.
The attraction is that the list is closed. A client able to form those URLs can ask everything the interface offers. There is no expression language to learn, nothing to escape, and nothing the server has to defend itself against beyond bounds it chooses for itself. EDR is the only way anything reads drogna's coverage store, and the query layer that serves it is pygeoapi, configured with two provider plugins written for this harness.
This page is a primer for a reader who has not met the standard. It is also a
record of what happens to a query once it arrives, which is where the standard's
cleanest idea meets an implementation that was not built for it. Everything
below about pygeoapi's behaviour describes pygeoapi 0.20.0, the release the
deployment pins, read from pygeoapi/api/environmental_data_retrieval.py and
pygeoapi/provider/base_edr.py rather than from documentation. Where it differs
from the 0.25 development line that drogna's spike measured, the difference is
called out, because that divergence is one of the more instructive things here.
The nine query types
A collection is a dataset. drogna publishes two: forecast, which carries
the current model run's forecast and uncertainty fields as parameters of one
collection, and observations, which is served through
SensorThings rather than EDR. A query type is one of nine
shapes, and it is a path segment in the URL rather than a parameter.
| Query type | The shape of the question | In drogna |
|---|---|---|
position |
One point. | Served |
radius |
A point and a distance around it. | Not served |
area |
A polygon. | Not served |
cube |
A bounding box, optionally with depth and time ranges. | Served |
trajectory |
A path, with a time at every vertex. | Served |
corridor |
A path swept into a volume: a trajectory given width and height. | Not served |
items |
The individually addressable things a collection holds. | Not served |
locations |
Named places the collection can be asked about. | Not served |
instances |
The versions of a collection. For drogna, the model runs. | Served |
Two of the five drogna does not serve turn out to be less available than the table suggests, for reasons that have nothing to do with drogna: see the difference between the nine.
Trajectory is the one drogna exists to exercise: given a planned route with a timestamp at every vertex, return the conditions forecast for the moment of arrival at each point, rather than the conditions at query time. For anything moving slowly through a changing medium — which is to say a ship — that is the only version of the question worth asking.
How a question is written
GET /collections/forecast/trajectory
?coords=LINESTRING ZM (-3.6 48.4 -5 1788220800, -2.55 49.45 -100 1788226668)
¶meter-name=sea_water_temperature
&datetime=2026-09-01T00:00:00Z/2026-09-02T00:00:00Z
&z=0
&f=json
Nothing about it is exotic, and that is the point. parameter-name selects
variables by name; datetime is an instant or a begin/end interval; z
selects depth; f selects the response format. The geometry travels in coords
as WKT.
WKT — Well-Known Text — is the ordinary text spelling of a geometry:
POINT (-3.6 48.4), LINESTRING (-3.6 48.4, -2.55 49.45). Past longitude and
latitude it allows two optional ordinates per vertex. Z is conventionally
elevation. M is a measure, deliberately left undefined by WKT itself:
whatever the application chooses. EDR chooses the vertex's time. So a
four-dimensional route is one string, and the timetable rides inside the geometry
rather than alongside it. It is a genuinely elegant fit.
drogna writes M as seconds since the Unix epoch. Feature 002's spike used that encoding and recorded honestly that it had not checked the choice against the specification text; it is the one assumption in the trajectory path that rests on convention rather than on measurement. The client and the provider agree on it in one named constant, because a disagreement between them would produce a plausible wrong answer rather than an error.
What actually happens to a query
The figure below is the mechanism, top to bottom, for pygeoapi 0.20.0. The prose after it says the same things in the same order, so nothing here depends on seeing it.
The framework's work is the top two-thirds of that picture, and it is worth
saying plainly what it amounts to. pygeoapi checks that the query type is one the
provider claims to support, validates datetime against the extent written in
the collection's configuration, rejects a parameter-name the provider does not
declare, validates bbox where a cube needs one, parses coords with
shapely.wkt.loads, packs twelve keyword arguments into a dict, and calls a
method on the provider named after the query type. That is the entire framework
contribution. Everything else — sampling, interpolation, the response document —
is the provider's.
The vertex time is decided before the plugin, not inside it
The single most useful thing the figure shows is that the coords step sits
inside the framework panel. pygeoapi parses the WKT and hands the resulting
geometry to the provider untouched, which is a virtue: nothing in the framework
has an opinion about what M means, so a provider is free to say that it means
arrival time. It is also the vulnerability, because whether M is still there when
the geometry arrives is settled by the installed geometry library, upstream of
every line of code drogna writes.
Shapely is a Python wrapper around GEOS, a C library, and the two fail independently. Feature 002 built three combinations and probed them with the same two strings.
| Shapely | GEOS | What happens to M |
|---|---|---|
| 2.1.2 | 3.13.1 | Every vertex time recovered exactly, in order. This is the pin. |
| ≥ 2.1 | < 3.12 | Returned as NaN. shapely.has_m raises UnsupportedGEOSVersionError here rather than returning False, so a guard written in terms of it errors out instead of failing informatively. |
| 2.0.x | ≥ 3.12 | Not NaN — absent. There is no include_m parameter and no has_m attribute to interrogate. LINESTRING ZM yields (x, y, z) tuples and round-trips back out as LINESTRING Z. This is the published pygeoapi image as it ships. |
| 2.0.x | < 3.12 | LINESTRING M comes back as a LINESTRING Z whose Z values are the timestamps, with has_z reporting true. A provider reading Z as elevation takes 1,788,220,800 for a depth in metres. |
Three ways to fail, and not one of them raises. No exception, no warning, no degraded status. The default outcome of losing M is a provider quietly evaluating the whole route at a single time and returning HTTP 200 with values that look entirely reasonable: for the twenty-vertex route the spike measured, wrong by 12.8 °C against 2.7 × 10⁻⁸ °C for the correct answer. The full account is in three ways to lose a timestamp.
drogna's response is in three parts. The deployment pins Shapely 2.1 or later
with the reason written at the pin. The image build parses a known
LINESTRING ZM and asserts that M comes back exactly and that Z is still the
elevations, because a check on M alone cannot tell a correct parse from one that
has moved the times into the depth axis. And the provider itself refuses: if any
M is NaN or the geometry parses to fewer than four ordinates per vertex, the
query is declined with a message naming the pin, rather than falling back to a
single time.
What the provider is handed, and what it is not
The dispatch dict is generous — query_type, instance, format_,
datetime_, select_properties, wkt, z, bbox, within, within_units,
limit and location_id — but the raw coords string is not in it. Only the
already-parsed geometry is. So if M ever stopped surviving the parse, a plugin
could not simply re-read the text: it would have to reach into the web
framework's request object from inside a provider, coupling the provider to
Flask and to pygeoapi's choice of it.
The collection next door has to do exactly that. drogna's SensorThings collection
is served through pygeoapi's Features interface, and there get() is handed an
identifier, a language and a CRS transform, and nothing else; an unrecognised
query parameter is dropped or rejected as an unknown property filter. The
$-prefixed options SensorThings needs cannot arrive any other way, so one
function reads them off the Flask request directly. That is the only place in the
query layer that reaches past the provider interface, and it is confined to two
short functions so that everything above it stays testable without a web request
in sight.
limit deserves a note of its own. The framework passes one to trajectory
queries, defaulting to ten. It is a records limit, and a provider that honoured
it naively would answer a twenty-vertex route with ten values and HTTP 200 —
precisely the class of failure this component is arranged to avoid. drogna's
provider ignores it and bounds routes by a configured vertex count instead,
refusing rather than truncating.
The difference between the nine, in full
The figure above shows one handler serving every query type. That raises a fair
question: if there is one handler, what actually makes a cube query different
from a trajectory one?
The answer is smaller than the standard makes it look. Between the nine query
types there are exactly three conditionals in the whole of
api/environmental_data_retrieval.py, and everything else every query type
receives is identical. The figure below is the full accounting; the prose after it
says the same things.
The three conditionals are these. bbox is validated only for cube and
locations, and is required for cube. coords is passed to
shapely.wkt.loads for every query type except those same two, and is required
— a position query without coords is a 400, and so is an area one. within
and within-units are read only for radius. That is the entire difference.
Everything else — datetime validated against the collection's extent,
parameter-name validated against the provider's fields, z passed through
unexamined, limit defaulting to ten — every routed query type gets the same.
instances is not in that accounting because it never reaches it: the route
handler notices an instances path and calls a different function before any
parameter parsing runs.
Two of the nine come out worse than the others, and both are worth knowing about before designing around them.
corridor is routed but disarmed. The standard defines a corridor as a
trajectory swept into a volume, and the parameters that do the sweeping are
corridor-width, corridor-height, their two unit parameters, and
resolution-x and resolution-z. None of those strings appears anywhere in
pygeoapi 0.20.0 outside the route declarations and the list of query type names.
They are not parsed, not validated, and not placed in the dispatch dict. A
provider answering a corridor query receives a parsed LINESTRING and no width
— which is to say it receives a trajectory query wearing a different name.
items has no route. EDR_QUERY_TYPES in provider/base_edr.py lists nine
names, and the EDR route tables in all three web frameworks pygeoapi ships —
Flask, Starlette and Django — declare eight of them. items is absent from every
one. The /collections/{id}/items path that does exist belongs to the Features
API and dispatches to the Features interface, so a provider that advertises EDR
items and implements the method will find that nothing can call it. The query
type can be advertised in the collection metadata and remain unreachable, and
nothing anywhere reports the discrepancy.
Neither of these affects drogna, which serves four query types and advertises
exactly those four. They are recorded because the reason drogna serves so few is
easy to misread as timidity, and it is not: position and cube are supplied by
the xarray-edr provider, trajectory and instances are the two this project
exists to write, and of the five it does not serve, one cannot be reached at all
and another cannot be answered correctly by anybody.
How a provider says what it can answer
The check at the top of the figure — is this query type advertised? — is why the registration mechanism matters, and pygeoapi has spelt it two different ways.
__dict__ in the 0.25 development line.In 0.20.0, BaseEDRProvider carries query_types = [] as a class attribute, and
a @BaseEDRProvider.register() decorator appends each decorated method's name to
it. The list belongs to the base, so every registration anywhere in the process
lands in the same list and providers see one another's types. In the 0.25
development line the decorator is gone: __init_subclass__ rebuilds
query_types from the subclass's own __dict__, so a plugin that subclasses a
supplied provider and adds only trajectory advertises only trajectory, and the
inherited position and cube vanish from the collection in silence.
The two failures are different in kind. One loses capability visibly in a single
collection; the other lets one provider's registration leak into another's. The
spike measured the development line and the deployment pins 0.20.0, so drogna
honours both mechanisms rather than choosing on the strength of a measurement
made against the other. The served collection was checked live and advertises
['cube', 'instances', 'position', 'trajectory'].
There is a general lesson underneath, and it is the reason this section exists in a primer at all: a measured finding is measured against a version. Pinning a version other than the one measured does not merely weaken the finding — it can replace the hazard with a different one.
What comes back
The response format is CoverageJSON, which separates the domain (where and when the values sit) from the ranges (the values) and the parameters (what each value means). A coverage is a function from positions in space and time to values; the three query types drogna answers each produce a different domain type.
| Query type | Domain type | Shape of the domain | Range |
|---|---|---|---|
position |
Point |
Axes x, y, z, t, each with a single value. |
NdArray of shape [1, 1, 1, 1] over t, z, y, x. |
cube |
Grid |
Axes x, y, z, t, each a list of grid values. |
NdArray over t, z, y, x, flat, in that declared order. |
trajectory |
Trajectory |
One composite axis whose every entry is a (t, x, y, z) tuple, one per vertex. |
NdArray of shape [n] over ["composite"]. |
The composite axis is what makes the trajectory response worth having. It is a
list of rows in the order they were asked for, so the browser client receives
exactly (t, x, y, z, value) per vertex with no reshaping to do — the standard's
own response shape happens to be the shape the client's centrepiece needs. Not to
be confused with CF's trajectoryProfile,
which describes data collected along a path rather than values requested along
one; the two meet in the query layer and nowhere else.
Every response carries a referencing block naming three systems: a geographic
CRS for x and y, a vertical CRS for z, and a temporal reference system for
t. The vertical one earns its place. WKT Z is elevation, positive up; the
coverage's axis is depth, positive down; drogna's provider applies depth = -z
and says so in the response, because a vertical axis left implicit will be read
upside down by somebody and the reading will look plausible.
Two more things a response says that a response usually does not. A vertex
falling outside the run's extent gets a null value and an entry under
drogna:declined naming the axis and the extent that excluded it — because a
null in an array is indistinguishable from a missing measurement, and this is
neither. And the provider interpolates linearly in all four dimensions rather
than snapping to the nearest stored time step, which is a choice invisible from
outside: snapping the acceptance test's route would have put a fifth of a degree
of the harness's own arithmetic into what it reports.
What drogna implements, and what it does not
position, cube, trajectory and instances, from a single provider class
over a single collection. One collection rather than two, because two could
disagree about which run they describe.
radius, area, corridor, items and locations are not served, and the
provider does not advertise them, so they are refused with 400 before any
drogna code runs. That is a decision rather than a gap: nothing in the harness
asks those questions, and a query type claimed but untested is a claim this
repository exists not to make.
The absence on the other side is more interesting. Of the nine query types,
pygeoapi 0.20.0 supplies implementations for only some, across exactly two EDR
providers: xarray-edr offers position and cube, and sensorthings-edr
offers items, locations, cube and area — but the latter is an HTTP
client against an external SensorThings service, so it cannot serve a store of
our own. No supplied provider implements trajectory, corridor or radius
at all. The standard expresses the trajectory query natively and the
implementations have not caught up, which is why drogna's centrepiece query needs
a bespoke plugin rather than a configuration entry. Where a standard is ahead of
its implementations, drogna writes the adapter and records the cost: a
compatibility surface against a provider base class that carries no compatibility
promise, guarded by an exact version pin that both providers check before they
serve anything.
Two limits are worth knowing because they are stated in refusals rather than applied by truncation. A cube is bounded by cell count. A trajectory is bounded at 91 vertices — measured, not chosen: at six decimal places, 91 vertices is a 4,081-byte request line that works and 92 is 4,125 bytes refused by the web server before pygeoapi sees it. There is no POST form of an EDR query; the endpoint answers 405. The ceiling is a server setting rather than anything about the standard, and it belongs with the limit it produces.
The question drogna needs it to answer
Whether a standards-based read interface can serve a four-dimensional trajectory query well enough to be the only read path, with no bespoke endpoint alongside it.
The answer so far is yes, with an asterisk that is not about the standard. The query is expressible natively, the response shape fits the client without translation, and the measured error against a field with a closed form is 6.1 × 10⁻¹¹ °C for the acceptance test's twenty-vertex route. What it cost was a provider that had to be written, a version pin that guards a silent failure, and a registration mechanism honoured twice because two releases spell it differently. None of that is visible from outside, which is the reason for writing it down here.
The standard itself
The authoritative document is the OGC API — Environmental Data Retrieval Standard (OGC 19-086r6), with an overview and the current work at ogcapi.ogc.org/edr. This page paraphrases none of it at length; where the two disagree, the standard is right and this page is a bug.