Skip to content

Commit 575c7a1

Browse files
Generate the OGC Moving Features read routes over the MEOS dispatcher
The temporal-geometry sequence, its derived velocity and distance queries, and a stored temporal property are served by a FastAPI router (mobilityapi/routers/movfeat.py) that dispatches the matching MEOS catalog function (temporal_as_mfjson, tpoint_speed, tpoint_cumulative_length) through the Dispatcher and WireCodec and shapes the MF-JSON result as the OGC envelope. acceleration returns 501 for the piecewise-constant motion model. A FeatureStore port abstracts the database read so the routes carry stub tests without a live MEOS; create_app mounts the router and takes the store.
1 parent 7eab4f1 commit 575c7a1

3 files changed

Lines changed: 238 additions & 1 deletion

File tree

mobilityapi/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@
1818
from fastapi import FastAPI
1919

2020
from .dispatcher import Dispatcher
21-
from .routers import catalog, functions
21+
from .routers import catalog, functions, movfeat
2222
from .wire import WireCodec
2323

2424

2525
def create_app(
2626
dispatcher: Dispatcher,
2727
codec: WireCodec,
2828
*,
29+
feature_store: object | None = None,
2930
title: str = "MobilityAPI",
3031
version: str = "0.1.0",
3132
) -> FastAPI:
@@ -52,8 +53,10 @@ def create_app(
5253
)
5354
app.state.dispatcher = dispatcher
5455
app.state.codec = codec
56+
app.state.feature_store = feature_store
5557

5658
app.include_router(catalog.router, prefix="/catalog", tags=["catalog"])
5759
app.include_router(functions.router, prefix="/functions", tags=["functions"])
60+
app.include_router(movfeat.router, tags=["movingfeatures"])
5861

5962
return app

mobilityapi/routers/movfeat.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""OGC API – Moving Features read routes generated over the MEOS dispatcher.
2+
3+
The "pure MEOS round-trip" resources — the temporal-geometry sequence and its
4+
derived velocity / distance queries, and a stored temporal property — are
5+
served by dispatching the matching MEOS function from the vendored catalog
6+
through the ``Dispatcher`` + ``WireCodec``, then shaping the MF-JSON result as
7+
the OGC envelope. The collection / feature lifecycle, persistence and the
8+
GeoJSON envelope have no MEOS equivalent and stay hand-written.
9+
10+
OGC resource → MEOS catalog function (the alignment map):
11+
12+
tgsequence (export) → temporal_as_mfjson
13+
tgsequence/{tg}/velocity → tpoint_speed
14+
tgsequence/{tg}/distance → tpoint_cumulative_length
15+
tproperties/{name} → temporal_as_mfjson
16+
17+
``acceleration`` returns 501: with linearly interpolated position the speed is
18+
piecewise-constant, so its derivative is zero within each segment and undefined
19+
at the vertices; the value is not approximated.
20+
21+
A ``FeatureStore`` port abstracts the database read so the routes are testable
22+
with stubs; production wires it to PyMEOS + psycopg2. Writes (sub-trajectory
23+
append, temporal-property creation) remain on the hand-written path until the
24+
catalog's input-function shapes are confirmed against a live MEOS.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from typing import Any, Protocol, runtime_checkable
30+
31+
from fastapi import APIRouter, HTTPException, Request
32+
33+
#: OGC derived measure → the MEOS catalog function that computes it.
34+
DERIVED_FN = {"velocity": "tpoint_speed", "distance": "tpoint_cumulative_length"}
35+
#: MEOS serialiser used to export a temporal value as MF-JSON.
36+
EXPORT_FN = "temporal_as_mfjson"
37+
38+
39+
@runtime_checkable
40+
class FeatureStore(Protocol):
41+
"""Database port. Values are MF-JSON (the wire shape the codec decodes).
42+
43+
Production wires this to PyMEOS + psycopg2; tests pass a stub.
44+
"""
45+
46+
def get_trajectory(self, cid: str, fid: str) -> Any | None: ...
47+
def get_property(self, cid: str, fid: str, name: str) -> Any | None: ...
48+
49+
50+
router = APIRouter()
51+
52+
53+
def _ctx(request: Request):
54+
state = request.app.state
55+
store = getattr(state, "feature_store", None)
56+
if store is None:
57+
raise HTTPException(501, "no FeatureStore is wired into the app")
58+
return state.dispatcher, state.codec, store
59+
60+
61+
def _dispatch_unary(dispatcher, codec, fn: str, value_mfjson: Any) -> Any:
62+
"""Decode an MF-JSON temporal value, dispatch a single-temporal-argument
63+
MEOS function, and re-encode the result as MF-JSON."""
64+
if not dispatcher.has(fn):
65+
raise HTTPException(501, f"MEOS function `{fn}` is not in the vendored catalog")
66+
sig = dispatcher.signature(fn)
67+
if not sig.params:
68+
raise HTTPException(500, f"MEOS function `{fn}` has no parameters to dispatch")
69+
pname = sig.params[0]["name"]
70+
arg = codec.decode("mfjson", value_mfjson)
71+
result = dispatcher.dispatch(fn, {pname: arg})
72+
return codec.encode("mfjson", result)
73+
74+
75+
def _temporal_property(name: str, type_token: str, mfjson: Any, self_href: str) -> dict:
76+
"""Shape a MEOS MF-JSON temporal value as an OGC ``temporalProperty``."""
77+
seq = mfjson if isinstance(mfjson, list) else [mfjson]
78+
return {
79+
"name": name,
80+
"type": type_token,
81+
"valueSequence": seq,
82+
"links": [{"rel": "self", "href": self_href}],
83+
}
84+
85+
86+
@router.get("/collections/{cid}/items/{fid}/tgsequence", summary="Temporal geometry (MF-JSON)")
87+
def get_tgsequence(cid: str, fid: str, request: Request) -> Any:
88+
dispatcher, codec, store = _ctx(request)
89+
trip = store.get_trajectory(cid, fid)
90+
if trip is None:
91+
raise HTTPException(404, "feature not found")
92+
return _dispatch_unary(dispatcher, codec, EXPORT_FN, trip)
93+
94+
95+
@router.get(
96+
"/collections/{cid}/items/{fid}/tgsequence/{tg}/{measure}",
97+
summary="Derived temporal-geometry query: velocity | distance (acceleration → 501)",
98+
)
99+
def get_derived(cid: str, fid: str, tg: str, measure: str, request: Request) -> dict:
100+
if measure == "acceleration":
101+
raise HTTPException(
102+
501,
103+
"acceleration is not derivable: linearly interpolated position gives a "
104+
"piecewise-constant speed, whose derivative is zero within each segment "
105+
"and undefined at the vertices",
106+
)
107+
fn = DERIVED_FN.get(measure)
108+
if fn is None:
109+
raise HTTPException(404, f"unknown temporal-geometry query: {measure}")
110+
dispatcher, codec, store = _ctx(request)
111+
trip = store.get_trajectory(cid, fid)
112+
if trip is None:
113+
raise HTTPException(404, "feature not found")
114+
out = _dispatch_unary(dispatcher, codec, fn, trip)
115+
return _temporal_property(measure, "TReal", out, request.url.path)
116+
117+
118+
@router.get(
119+
"/collections/{cid}/items/{fid}/tproperties/{name}",
120+
summary="A stored temporal property as an OGC temporalProperty",
121+
)
122+
def get_tproperty(cid: str, fid: str, name: str, request: Request) -> dict:
123+
dispatcher, codec, store = _ctx(request)
124+
value = store.get_property(cid, fid, name)
125+
if value is None:
126+
raise HTTPException(404, f"unknown temporal property: {name}")
127+
out = _dispatch_unary(dispatcher, codec, EXPORT_FN, value)
128+
return _temporal_property(name, "TReal", out, request.url.path)

tests/test_movfeat.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""HTTP-surface tests for the generated OGC API – Moving Features routes.
2+
3+
The routes are exercised via Starlette's ``TestClient`` against a tiny in-test
4+
catalog, a stub resolver (so the "MEOS function" is a Python lambda), a stub
5+
``WireCodec``, and a stub ``FeatureStore``. PyMEOS and a database are NOT
6+
required — the test asserts the dispatch + OGC-shaping wiring, not a live MEOS.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
from pathlib import Path
13+
14+
import pytest
15+
from fastapi.testclient import TestClient
16+
17+
from mobilityapi import create_app, stub_codec
18+
from mobilityapi.dispatcher import Dispatcher
19+
20+
# Catalog: the three MEOS functions the OGC read routes dispatch to, each a
21+
# single-temporal-argument function named `t`.
22+
_CATALOG = {
23+
"functions": [
24+
{"name": "temporal_as_mfjson", "category": "io", "params": [{"name": "t", "type": "Temporal *"}], "return_type": "text"},
25+
{"name": "tpoint_speed", "category": "analysis", "params": [{"name": "t", "type": "Temporal *"}], "return_type": "Temporal *"},
26+
{"name": "tpoint_cumulative_length", "category": "analysis", "params": [{"name": "t", "type": "Temporal *"}], "return_type": "Temporal *"},
27+
]
28+
}
29+
30+
# Stub resolver: each "MEOS function" tags its single argument so the response
31+
# is traceable back to the function that ran.
32+
_RESOLVERS = {
33+
"temporal_as_mfjson": lambda **kw: {"mfjson_of": kw["t"]},
34+
"tpoint_speed": lambda **kw: {"speed_of": kw["t"]},
35+
"tpoint_cumulative_length": lambda **kw: {"distance_of": kw["t"]},
36+
}
37+
38+
39+
class _StubStore:
40+
"""In-memory FeatureStore: vessel 1 exists with a trip + a `fuel` property."""
41+
42+
def get_trajectory(self, cid, fid):
43+
return {"type": "MovingPoint", "id": fid} if fid == "1" else None
44+
45+
def get_property(self, cid, fid, name):
46+
return {"type": "MovingFloat", "prop": name} if (fid == "1" and name == "fuel") else None
47+
48+
49+
@pytest.fixture
50+
def client(tmp_path: Path) -> TestClient:
51+
cat = tmp_path / "meos-idl.json"
52+
cat.write_text(json.dumps(_CATALOG))
53+
dispatcher = Dispatcher(catalog_path=cat, resolver=lambda n: _RESOLVERS[n])
54+
codec = stub_codec(
55+
decoders={"mfjson": lambda v: {"decoded": v}},
56+
encoders={"mfjson": lambda o: o},
57+
)
58+
return TestClient(create_app(dispatcher, codec, feature_store=_StubStore()))
59+
60+
61+
def test_tgsequence_export_dispatches_as_mfjson(client):
62+
r = client.get("/collections/ships/items/1/tgsequence")
63+
assert r.status_code == 200
64+
assert "mfjson_of" in r.json()
65+
66+
67+
def test_tgsequence_missing_feature_404(client):
68+
assert client.get("/collections/ships/items/999/tgsequence").status_code == 404
69+
70+
71+
@pytest.mark.parametrize("measure,tag", [("velocity", "speed_of"), ("distance", "distance_of")])
72+
def test_derived_measure_is_a_temporal_property(client, measure, tag):
73+
r = client.get(f"/collections/ships/items/1/tgsequence/0/{measure}")
74+
assert r.status_code == 200
75+
body = r.json()
76+
assert body["name"] == measure
77+
assert body["type"] == "TReal"
78+
assert tag in json.dumps(body["valueSequence"])
79+
80+
81+
def test_acceleration_is_501(client):
82+
r = client.get("/collections/ships/items/1/tgsequence/0/acceleration")
83+
assert r.status_code == 501
84+
assert "not derivable" in r.json()["detail"]
85+
86+
87+
def test_unknown_measure_404(client):
88+
assert client.get("/collections/ships/items/1/tgsequence/0/heading").status_code == 404
89+
90+
91+
def test_stored_temporal_property(client):
92+
r = client.get("/collections/ships/items/1/tproperties/fuel")
93+
assert r.status_code == 200
94+
assert r.json()["name"] == "fuel"
95+
96+
97+
def test_unknown_temporal_property_404(client):
98+
assert client.get("/collections/ships/items/1/tproperties/nope").status_code == 404
99+
100+
101+
def test_routes_501_without_a_feature_store(tmp_path: Path):
102+
cat = tmp_path / "meos-idl.json"
103+
cat.write_text(json.dumps(_CATALOG))
104+
dispatcher = Dispatcher(catalog_path=cat, resolver=lambda n: _RESOLVERS[n])
105+
app = create_app(dispatcher, stub_codec(), feature_store=None)
106+
assert TestClient(app).get("/collections/ships/items/1/tgsequence").status_code == 501

0 commit comments

Comments
 (0)