drogna

A demonstration harness. Every number in it is invented.

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)
    &parameter-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.

How pygeoapi handles an OGC API-EDR query, and what each query type meets A top-to-bottom flow in four stages. First, the request: a GET to /collections/forecast/{query type} with coords, datetime, parameter-name, z, bbox, within and limit as optional query-string parameters. Second, a panel labelled pygeoapi 0.20.0, api/environmental_data_retrieval.py, containing everything that runs before any drogna code and none of it configurable. Inside that panel: query_type is checked against the provider's advertised list, and a type not in it returns 400 Unsupported query type; coords is passed to shapely.wkt.loads and the resulting geometry is handed on untouched, which is where the M ordinate carrying every vertex's arrival time either survives or is silently lost, decided by the installed Shapely and GEOS versions in three ways, none of which raises; datetime is validated against the collection's configured extent; parameter-name is checked against the provider's fields and an unknown name returns 400; bbox is validated and required only for cube; z, within and limit are passed through unexamined, with limit defaulting to ten and counting records rather than vertices; and the raw query strings are not passed on at all, so the provider receives the parsed geometry and never the text it came from. Third, dispatch: twelve keyword arguments in one dict, then BaseEDRProvider.query calls getattr on the provider for the query type's name. Fourth, the fan-out. Four query types are answered by drogna's plugin class: position, a single point, supplied by the xarray-edr provider, returning a CoverageJSON Point domain; cube, a bounding box, also supplied, returning a Grid domain; trajectory, a path with a time at every vertex, for which pygeoapi supplies nothing at all, returning a Trajectory domain; and instances, which model run, for which pygeoapi also supplies nothing, returning a list of run identifiers. Five query types are refused because drogna's provider does not advertise them: radius, area, corridor, items and locations, each returning 400 Unsupported query type before the provider is called. A marker labelled M appears twice: on the coords step where the per-vertex arrival time is decided, and on the trajectory query type, the only one that depends on it. GET /collections/forecast/{query type}?coords=…&datetime=… plus parameter-name, z, bbox, within, limit — all optional, all query string PYGEOAPI 0.20.0 · api/environmental_data_retrieval.py Everything in this panel runs before any drogna code, and none of it is configurable.

query_type Is it in the provider's advertised list? If not, 400 Unsupported query type.

M coords → shapely.wkt.loads(coords) → a Shapely geometry, handed on untouched The M ordinate — every vertex's arrival time — either survives this call or is silently lost. Decided by the installed Shapely and GEOS. Three ways to lose it, and none of them raises.

datetime validate_datetime() against the collection's configured extent. 400 outside it. parameter-name Checked against the provider's get_fields(). 400 InvalidParameterValue. bbox validate_bbox(). Required for cube; not read for the others. z · within · limit Passed through unexamined. limit defaults to 10 and counts records, not vertices. the raw strings Not passed on. The provider gets the parsed geometry, never the text it came from.

query_args — twelve keyword arguments in one dict BaseEDRProvider.query() → getattr(self, query_type)(**query_args) ANSWERED — ONE DROGNA PROVIDER CLASS position a single point supplied: xarray-edr → Point domain cube a bounding box supplied: xarray-edr → Grid domain M trajectory a path with a time at every vertex supplied: nothing → Trajectory domain instances which model run, named or current → a list of run ids REFUSED — NOT ADVERTISED radius · area · corridor items · locations 400 Unsupported query type, before the provider is called. M marks where the per-vertex arrival time is decided, and the one query type that depends on it.
An EDR query in pygeoapi 0.20.0: what the framework does to each parameter, how it dispatches, and what each of the nine query types meets in drogna's collection.

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.

What pygeoapi parses for each EDR query type, and what it does not A table drawn as a grid, nine rows and six columns, showing what pygeoapi 0.20.0 parses for each OGC API-EDR query type before it calls a provider. The columns are: coords, which is passed to shapely.wkt.loads; bbox; within and within-units; location_id taken from the path; and the parameters common to every query type, namely datetime, z, parameter-name and limit. position, radius, area, trajectory and corridor all require coords. radius alone also reads within and within-units. cube does not read coords at all and requires bbox instead. locations does not read coords either, reads bbox optionally, and takes a location_id from the path. Every one of those receives the common parameters. Two rows are marked as losing something. corridor is routed and its coords are parsed, but corridor-width, corridor-height, their units and the two resolution parameters are not read anywhere in the release, so the parameters that make a corridor a corridor never reach the provider. items is worse: no EDR route is registered for it at all in any of the three web frameworks pygeoapi ships, so the whole query type is unreachable; the /items path that does exist belongs to the Features API, not to EDR. trajectory is marked with an M badge: it is the only query type whose coords carry a per-vertex time, and whether that time survives shapely.wkt.loads depends on the installed Shapely and GEOS versions. instances is answered by a different function entirely, before any of this parsing runs. Of the nine, drogna serves four: position, cube, trajectory and instances. PYGEOAPI 0.20.0 · WHAT REACHES THE PROVIDER, BY QUERY TYPE One handler serves every routed query type. These five conditionals are the whole of the difference between them. coords shapely.wkt.loads bbox within within-units location_id from the path common datetime · z · limit parameter-name what never arrives of what this query type is defined by position served required radius not advertised required area not advertised required cube served not read required trajectory served required M nothing — but the M ordinate inside coords may not survive the parse corridor not advertised required corridor-width, corridor-height, their units, resolution-x, resolution-z items not advertised no route — nothing is parsed everything — no EDR route is registered for this query type at all locations not advertised not read optional instances served not parsed here at all nothing — it is answered by a different function, before any of the above Every routed query type also receives datetime validated against the collection's extent, parameter-name validated against the provider's fields, z passed through unexamined, and limit — which counts records, defaults to ten, and is not a vertex bound.
What pygeoapi 0.20.0 parses for each EDR query type before calling a provider: five conditionals, of which three are the whole difference between the nine. Two query types lose something on the way, and one of those never arrives at all.

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.

Two mechanisms for advertising EDR query types, and their different failures Two panels side by side. Left, pygeoapi 0.20.0, the release drogna pins: BaseEDRProvider carries a class attribute query_types set to an empty list, and a decorator, at-BaseEDRProvider-dot-register, appends each decorated method's name to it. Two providers are shown below it — the supplied xarray-edr provider registering position, and another provider registering area — both appending into that same single list. Because the list belongs to the base class, every provider in the process shares it, so one collection can end up advertising a query type that a different provider registered. Right, pygeoapi 0.25.dev0, the development line the spike measured: there is no decorator; instead __init_subclass__ rebuilds query_types from the subclass's own __dict__, keeping only names that are EDR query types. A subclass that adds only a trajectory method therefore advertises exactly trajectory, and the inherited position and cube methods stop being advertised. That is not an error; the collection simply stops offering two query types its provider can still answer. Below both panels, a highlighted strip: drogna satisfies both mechanisms rather than choosing between them. Every query type it serves is a method in the class's own __dict__ and is also named in a query_types list set on the class, which shadows the base's shared one, and a test asserts both. PYGEOAPI 0.20.0 — THE RELEASE DROGNA PINS class BaseEDRProvider: query_types = [] ← one list, on the base XarrayEDRProvider @register() position any other provider @register() area @BaseEDRProvider.register() appends each name to that one list. It belongs to the base class, so every provider in the process shares it and sees the others' types. PYGEOAPI 0.25.dev0 — THE LINE THE SPIKE MEASURED def __init_subclass__(cls): cls.query_types = [n for n in cls.__dict__ if n in EDR_QUERY_TYPES] A subclass that adds only trajectory() advertises exactly ['trajectory']. The inherited position and cube stop being advertised. Not an error: the collection simply stops offering two query types that its provider can still answer. drogna satisfies both mechanisms rather than choosing between them. Every query type it serves is a method in the class's own __dict__, and is also named in a query_types list set on the class, which shadows the base's shared one. A test asserts the collection advertises all three.
Advertising query types: a shared mutable list on the base class in pygeoapi 0.20.0, versus a per-subclass rebuild from __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.