diff --git a/.gitignore b/.gitignore index e87f48b..a442c24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ mfapi-go -MobilityAPI-go *.log diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 328dcad..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,16 +0,0 @@ -------------------------------------------------------------------------------- -This MobilityAPI code is provided under The PostgreSQL License. - -Copyright (c) 2024, Université libre de Bruxelles and MobilityAPI contributors - -Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby -granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. - -IN NO EVENT SHALL UNIVERSITE LIBRE DE BRUXELLES BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST -PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF UNIVERSITE LIBRE DE BRUXELLES HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. - -UNIVERSITE LIBRE DE BRUXELLES SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND UNIVERSITE LIBRE DE BRUXELLES HAS NO OBLIGATIONS TO PROVIDE -MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -------------------------------------------------------------------------------- diff --git a/MobilityAPIV6.pdf b/MobilityAPIV6.pdf deleted file mode 100755 index a3a5b70..0000000 Binary files a/MobilityAPIV6.pdf and /dev/null differ diff --git a/MobilityAPIV6.pptx b/MobilityAPIV6.pptx deleted file mode 100755 index 7b9cd6a..0000000 Binary files a/MobilityAPIV6.pptx and /dev/null differ diff --git a/README.md b/README.md deleted file mode 100644 index 2391551..0000000 --- a/README.md +++ /dev/null @@ -1,94 +0,0 @@ -MobilityAPI -=========== - -[![License: PostgreSQL](https://img.shields.io/badge/License-PostgreSQL-blue.svg)](https://www.postgresql.org/about/licence/) -[![Go 1.25+](https://img.shields.io/badge/go-1.25+-00ADD8.svg)](https://go.dev/) -[![OGC API – Moving Features](https://img.shields.io/badge/OGC%20API-Moving%20Features-green.svg)](https://docs.ogc.org/is/22-003r3/22-003r3.html) - -The reference implementation of the [OGC API – Moving Features Standard](https://docs.ogc.org/is/22-003r3/22-003r3.html) over [MobilityDB](https://github.com/MobilityDB/MobilityDB) — a thin, compiled HTTP tier for clients that don't speak SQL or the PostgreSQL wire protocol. - -## What it is - -MobilityAPI exposes MobilityDB collections of moving features over plain HTTP — for browser apps, mobile clients, microservices, and ETL / lakehouse pipelines. It is a thin tier: every temporal computation and (de)serialization runs inside MobilityDB itself (`asMFJSON`, `atTime`, `tgeompointFromMFJSON`, `appendInstant`). The server holds no MEOS — no cgo, no embedded library — so it stays small and stateless, and the database is the single source of truth. - -- **Streaming responses** — a FeatureCollection is written row by row from a server-side cursor, so memory is bounded regardless of result size. -- **Keyset pagination** — `WHERE id > :after` with OGC `next` links, no `OFFSET`, constant cost through large collections. -- **Index-using filters** — `bbox` and `datetime` push to the MobilityDB GiST index; `subtrajectory` clips with `atTime`. -- **Lakehouse export** — a streaming `GET …/export` feed (NDJSON, or `?format=parquet` with the trajectory WKB plus a bbox/time sidecar) that DuckDB / MobilityDuck / Spark can ingest directly. - -This Go server is the reference (production) implementation. A PyMEOS-based Python implementation is also available at [MobilityAPI-Python](https://github.com/MobilityDB/MobilityAPI-Python). - -## Architecture - -``` -HTTP client ──▶ MobilityAPI (Go · net/http + pgxpool) ──▶ MobilityDB / PostgreSQL -``` - -The tier translates between OGC API – Moving Features requests/responses and MobilityDB SQL; all geometry and temporal logic lives in the database. - -## Endpoints - -| Method | Path | Description | -|---|---|---| -| GET | `/` · `/api` · `/conformance` | Landing page, OpenAPI document, conformance declaration | -| GET · POST · PUT · DELETE | `/collections` · `/collections/{cid}` | List, register, replace and delete collections (spatial + temporal extent) | -| GET | `/collections/{cid}/items` | Moving features, streamed and keyset-paged, with `bbox` / `datetime` / `subtrajectory` filters | -| GET · POST · PUT · DELETE | `/collections/{cid}/items/{fid}` | Read, replace and delete a moving feature; `POST` on the collection creates one from a MovingFeatureJSON body | -| GET · POST · DELETE | `/collections/{cid}/items/{fid}/tgsequence` | The temporal geometry (MF-JSON); append a temporally-disjoint sub-trajectory | -| GET | `/collections/{cid}/items/{fid}/tgsequence/{tgid}/{distance\|velocity}` | Derived kinematics; `acceleration` returns 501 for the piecewise-constant motion model | -| GET · POST · DELETE | `/collections/{cid}/items/{fid}/tproperties` · `/.../{pname}` | User-supplied, stored temporal properties (`TReal` · `TInt` · `TText` · `TBool`), held as native MobilityDB temporal values | -| GET | `/collections/{cid}/export` | Lakehouse feed: NDJSON, or `?format=parquet` | -| POST | `/collections/{cid}/bulk` | Bulk ingest of a fleet feed: GeoJSON / GeoParquet observations, each appended as one instant | - -`GET /export`, `PUT`, and `POST /bulk` are opt-in extensions beyond the OGC conformance classes declared at `GET /conformance`. The surface is mapped against the OGC standard and the `aistairc/mf-api` reference in [`docs/mf-api-comparison.md`](docs/mf-api-comparison.md). - -## Prerequisites - -- Go 1.25 or later -- PostgreSQL with the [MobilityDB](https://github.com/MobilityDB/MobilityDB) extension (and PostGIS) - -## Build and run - -```bash -go build -o mfapi . -MFAPI_DSN="postgres:///mydb?host=/var/run/postgresql&port=5432&user=me" ./mfapi -``` - -The server listens on `:8088` by default. Configuration is by environment variable: - -| Variable | Default | Meaning | -|---|---|---| -| `MFAPI_DSN` | `postgres:///mfapi_demo?host=/tmp&port=5432&user=esteban` | MobilityDB connection string | -| `MFAPI_PORT` | `8088` | Listen port | -| `MFAPI_MAXCONNS` | `16` | Connection-pool size | -| `MFAPI_DEFAULT_LIMIT` / `MFAPI_MAX_LIMIT` | `100` / `10000` | Page size defaults and ceiling | -| `MFAPI_EXPORT_LIMIT` | `0` (unbounded) | Rows per export stream | -| `MFAPI_PARQUET_ROWGROUP` | `1024` | Rows per Parquet row group (bounds export memory) | - -## Test - -```bash -go test ./... -``` - -## Repository layout - -- `main.go` — the server: routing, OGC request/response shaping, streaming and export. -- `bulk.go` — the bulk-ingest extension endpoint (GeoJSON / GeoParquet fleet feed). -- `bench/` — a comparison harness and results. -- `tutorial/` — a notebook walkthrough of the endpoints against the canonical AIS dataset. -- `docs/` — the OGC standard / `aistairc/mf-api` surface comparison. - -## Where MobilityAPI fits - -MobilityAPI is the HTTP / OGC layer of the MEOS ecosystem: - -- **MEOS** (canonical C library) — the underlying type system and computations. -- **MobilityDB** · **MobilityDuck** · **MobilitySpark** — peer SQL surfaces over MEOS. -- **Language bindings** — [PyMEOS](https://github.com/MobilityDB/PyMEOS), [JMEOS](https://github.com/MobilityDB/JMEOS), [meos-rs](https://github.com/MobilityDB/meos-rs), [GoMEOS](https://github.com/MobilityDB/GoMEOS), [MEOS.NET](https://github.com/MobilityDB/MEOS.NET), [MEOS.js](https://github.com/MobilityDB/MEOS.js). - -A longer overview is at [libmeos.org](https://libmeos.org/). - -## License - -MobilityAPI is released under [The PostgreSQL License](https://www.postgresql.org/about/licence/). diff --git a/bulk.go b/bulk.go deleted file mode 100644 index bd91dad..0000000 --- a/bulk.go +++ /dev/null @@ -1,300 +0,0 @@ -// Bulk ingestion of a real-time fleet feed for the OGC API – Moving Features -// tier (extension, not in conformsTo): -// -// POST /collections/{cid}/bulk -// -// The body is a batch of (vehicleId, position, time) observations — every minute -// a city posts one point per vehicle — and each observation is appended as one -// instant to the matching moving feature's tgeompoint trajectory, creating the -// feature on first sight. The batch may be encoded as GeoJSON (a FeatureCollection -// of Point features) or GeoParquet (one row per observation), and may be -// compressed via Content-Encoding (gzip, deflate, br, zstd). The whole batch -// commits atomically. As everywhere in this tier, the geometry and temporal work -// run inside MobilityDB (ST_MakePoint / ST_GeomFromWKB, tgeompoint, appendInstant); -// the tier only decodes the wire format. -package main - -import ( - "bytes" - "compress/flate" - "compress/gzip" - "compress/zlib" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - "strings" - - "github.com/andybalholm/brotli" - "github.com/klauspost/compress/zstd" - "github.com/parquet-go/parquet-go" -) - -// bulkMaxBytes bounds the (decompressed) request body the tier will buffer. -var bulkMaxBytes = int64(envInt("MFAPI_BULK_MAXBYTES", 64<<20)) - -// observation is one (id, position, time) sample. GeoJSON sets x/y; GeoParquet -// carries the point as WKB, decoded by PostGIS rather than the tier. -type observation struct { - id string - x float64 - y float64 - wkb []byte - t string -} - -// bulkPqRow is the GeoParquet ingest shape: one row per observation, symmetric to -// the WKB-plus-sidecar export. ts is an ISO-8601 string so any producer's clock -// representation survives the round trip without a logical-type negotiation. -type bulkPqRow struct { - Geometry []byte `parquet:"geometry"` - ID string `parquet:"id"` - TS string `parquet:"ts"` -} - -func bulkIngest(w http.ResponseWriter, r *http.Request) { - tbl, srid, ok := collectionMeta(r.Context(), r.PathValue("cid")) - if !ok { - httpErr(w, 404, "collection not found") - return - } - - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, bulkMaxBytes)) - if err != nil { - httpErr(w, 413, "request body too large or unreadable: "+err.Error()) - return - } - body, err = decompress(body, r.Header.Get("Content-Encoding")) - if err != nil { - httpErr(w, 415, err.Error()) - return - } - - ct := strings.ToLower(r.Header.Get("Content-Type")) - var obs []observation - switch { - case strings.Contains(ct, "parquet"): - obs, err = parseGeoParquet(body) - default: - obs, err = parseGeoJSONPoints(body) - } - if err != nil { - httpErr(w, 400, err.Error()) - return - } - - created, extended, err := appendBatch(r.Context(), tbl, srid, obs) - if err != nil { - if isOrderingError(err) { - httpErr(w, 409, "an observation is not strictly after the feature's last instant: "+err.Error()) - return - } - httpErr(w, 400, "ingest failed: "+err.Error()) - return - } - writeJSON(w, 201, map[string]any{ - "observations": len(obs), - "featuresCreated": created, - "featuresExtended": extended, - }) -} - -// appendBatch appends every observation as one instant, in one transaction so the -// whole batch is atomic. A feature is created on first sight and extended with -// appendInstant afterwards; only the id and trip columns are touched, so the -// collection's feature table may carry any other columns. -func appendBatch(ctx context.Context, tbl string, srid int, obs []observation) (created, extended int, err error) { - tx, err := pool.Begin(ctx) - if err != nil { - return 0, 0, err - } - defer tx.Rollback(ctx) - - for _, o := range obs { - var inst string - var args []any - if o.wkb != nil { - inst = "tgeompoint(ST_SetSRID(ST_GeomFromWKB($1),$2),$3::timestamptz)" - args = []any{o.wkb, srid, o.t} - } else { - inst = "tgeompoint(ST_SetSRID(ST_MakePoint($1,$2),$3),$4::timestamptz)" - args = []any{o.x, o.y, srid, o.t} - } - idP := "$" + strconv.Itoa(len(args)+1) - tag, e := tx.Exec(ctx, - "UPDATE "+ident(tbl)+" SET trip = appendInstant(trip, "+inst+") WHERE id="+idP, - append(args, o.id)...) - if e != nil { - return 0, 0, e - } - if tag.RowsAffected() > 0 { - extended++ - continue - } - if _, e := tx.Exec(ctx, - "INSERT INTO "+ident(tbl)+"(id, trip) VALUES ("+idP+", "+inst+")", - append(args, o.id)...); e != nil { - return 0, 0, e - } - created++ - } - if err := tx.Commit(ctx); err != nil { - return 0, 0, err - } - return created, extended, nil -} - -// parseGeoJSONPoints reads a FeatureCollection of Point features, each carrying a -// vehicle id and a timestamp, into observations. -func parseGeoJSONPoints(body []byte) ([]observation, error) { - var fc struct { - Type string `json:"type"` - Features []struct { - ID json.RawMessage `json:"id"` - Geometry struct { - Type string `json:"type"` - Coordinates []float64 `json:"coordinates"` - } `json:"geometry"` - Properties map[string]any `json:"properties"` - When json.RawMessage `json:"when"` - } `json:"features"` - } - if err := json.Unmarshal(body, &fc); err != nil { - return nil, errors.New("invalid GeoJSON: " + err.Error()) - } - if fc.Type != "FeatureCollection" { - return nil, errors.New("bulk GeoJSON ingest expects a FeatureCollection") - } - obs := make([]observation, 0, len(fc.Features)) - for _, f := range fc.Features { - if f.Geometry.Type != "Point" { - return nil, errors.New("bulk ingest expects Point geometries") - } - if len(f.Geometry.Coordinates) < 2 { - return nil, errors.New("a Point needs [x, y] coordinates") - } - id := jsonScalar(f.ID) - if id == "" { - id = scalar(f.Properties["id"]) - } - if id == "" { - return nil, errors.New("each feature needs an id (the vehicle identifier)") - } - t := scalar(f.Properties["datetime"]) - for _, k := range []string{"time", "timestamp", "t"} { - if t == "" { - t = scalar(f.Properties[k]) - } - } - if t == "" { - t = jsonScalar(f.When) - } - if t == "" { - return nil, errors.New("each feature needs a timestamp (properties.datetime)") - } - obs = append(obs, observation{id: id, x: f.Geometry.Coordinates[0], y: f.Geometry.Coordinates[1], t: t}) - } - return obs, nil -} - -// parseGeoParquet reads one observation per row (geometry WKB point, id, ts). -func parseGeoParquet(body []byte) ([]observation, error) { - f, err := parquet.OpenFile(bytes.NewReader(body), int64(len(body))) - if err != nil { - return nil, errors.New("invalid GeoParquet: " + err.Error()) - } - pr := parquet.NewGenericReader[bulkPqRow](f) - defer pr.Close() - obs := make([]observation, 0, pr.NumRows()) - buf := make([]bulkPqRow, 512) - for { - n, e := pr.Read(buf) - for _, row := range buf[:n] { - if len(row.Geometry) == 0 { - return nil, errors.New("GeoParquet row is missing the geometry column") - } - if row.ID == "" || row.TS == "" { - return nil, errors.New("GeoParquet row is missing the id or ts column") - } - obs = append(obs, observation{id: row.ID, wkb: row.Geometry, t: row.TS}) - } - if e == io.EOF { - break - } - if e != nil { - return nil, errors.New("GeoParquet read failed: " + e.Error()) - } - } - return obs, nil -} - -// decompress transparently decodes a request body by its Content-Encoding. gzip, -// deflate, br and zstd are all supported; deflate accepts both the zlib-wrapped -// and raw stream forms seen in the wild. -func decompress(body []byte, encoding string) ([]byte, error) { - switch strings.ToLower(strings.TrimSpace(encoding)) { - case "", "identity": - return body, nil - case "gzip", "x-gzip": - zr, err := gzip.NewReader(bytes.NewReader(body)) - if err != nil { - return nil, errors.New("invalid gzip body: " + err.Error()) - } - return io.ReadAll(zr) - case "deflate": - if zr, err := zlib.NewReader(bytes.NewReader(body)); err == nil { - return io.ReadAll(zr) - } - return io.ReadAll(flate.NewReader(bytes.NewReader(body))) - case "br": - return io.ReadAll(brotli.NewReader(bytes.NewReader(body))) - case "zstd": - zr, err := zstd.NewReader(bytes.NewReader(body)) - if err != nil { - return nil, errors.New("invalid zstd body: " + err.Error()) - } - defer zr.Close() - return io.ReadAll(zr) - default: - return nil, errors.New("unsupported Content-Encoding: " + encoding) - } -} - -// isOrderingError recognizes MobilityDB's rejection of an instant that is not -// strictly after the trajectory's last instant. -func isOrderingError(err error) bool { - m := strings.ToLower(err.Error()) - return strings.Contains(m, "increasing") || strings.Contains(m, "overlap") || - strings.Contains(m, "ordered") || (strings.Contains(m, "must be") && strings.Contains(m, "after")) -} - -// jsonScalar renders a JSON id/timestamp token (string or number) as plain text. -func jsonScalar(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var s string - if err := json.Unmarshal(raw, &s); err == nil { - return s - } - return strings.Trim(string(raw), `"`) -} - -// scalar renders a decoded JSON value (string or number) as plain text. -func scalar(v any) string { - switch x := v.(type) { - case string: - return x - case json.Number: - return x.String() - case float64: - return strconv.FormatFloat(x, 'f', -1, 64) - case nil: - return "" - default: - b, _ := json.Marshal(x) - return strings.Trim(string(b), `"`) - } -} diff --git a/bulk_test.go b/bulk_test.go deleted file mode 100644 index 647ab14..0000000 --- a/bulk_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package main - -import ( - "bytes" - "compress/flate" - "compress/gzip" - "compress/zlib" - "testing" - - "github.com/andybalholm/brotli" - "github.com/klauspost/compress/zstd" - "github.com/parquet-go/parquet-go" -) - -// decompress accepts every Content-Encoding the bulk endpoint advertises. -func TestDecompressRoundTrip(t *testing.T) { - payload := []byte(`{"type":"FeatureCollection","features":[]}`) - gzbuf := &bytes.Buffer{} - gw := gzip.NewWriter(gzbuf) - gw.Write(payload) - gw.Close() - zlbuf := &bytes.Buffer{} - zw := zlib.NewWriter(zlbuf) - zw.Write(payload) - zw.Close() - rawbuf := &bytes.Buffer{} - fw, _ := flate.NewWriter(rawbuf, flate.DefaultCompression) - fw.Write(payload) - fw.Close() - brbuf := &bytes.Buffer{} - bw := brotli.NewWriter(brbuf) - bw.Write(payload) - bw.Close() - zsbuf := &bytes.Buffer{} - sw, _ := zstd.NewWriter(zsbuf) - sw.Write(payload) - sw.Close() - - cases := map[string][]byte{ - "": payload, - "identity": payload, - "gzip": gzbuf.Bytes(), - "deflate": zlbuf.Bytes(), // zlib-wrapped deflate - "br": brbuf.Bytes(), - "zstd": zsbuf.Bytes(), - } - for enc, body := range cases { - got, err := decompress(body, enc) - if err != nil { - t.Fatalf("decompress(%q) error: %v", enc, err) - } - if !bytes.Equal(got, payload) { - t.Fatalf("decompress(%q) = %q, want %q", enc, got, payload) - } - } - // raw (headerless) deflate is accepted via the zlib->flate fallback - if got, err := decompress(rawbuf.Bytes(), "deflate"); err != nil || !bytes.Equal(got, payload) { - t.Fatalf("raw deflate fallback failed: %v / %q", err, got) - } - if _, err := decompress(payload, "lzma"); err == nil { - t.Fatal("unsupported Content-Encoding should error") - } -} - -func TestParseGeoJSONPoints(t *testing.T) { - body := []byte(`{"type":"FeatureCollection","features":[ - {"type":"Feature","id":"bus_42","geometry":{"type":"Point","coordinates":[4.3517,50.8466]},"properties":{"datetime":"2026-02-26T10:00:00Z"}}, - {"type":"Feature","geometry":{"type":"Point","coordinates":[4.349,50.8501]},"properties":{"id":"bus_57","time":"2026-02-26T10:00:00Z"}} - ]}`) - obs, err := parseGeoJSONPoints(body) - if err != nil { - t.Fatal(err) - } - if len(obs) != 2 { - t.Fatalf("got %d observations, want 2", len(obs)) - } - if obs[0].id != "bus_42" || obs[0].x != 4.3517 || obs[0].t != "2026-02-26T10:00:00Z" { - t.Fatalf("first observation parsed wrong: %+v", obs[0]) - } - if obs[1].id != "bus_57" { // id and time pulled from properties - t.Fatalf("second observation id from properties failed: %+v", obs[1]) - } - if _, err := parseGeoJSONPoints([]byte(`{"type":"Feature"}`)); err == nil { - t.Fatal("non-FeatureCollection should error") - } -} - -func TestParseGeoParquet(t *testing.T) { - var buf bytes.Buffer - w := parquet.NewGenericWriter[bulkPqRow](&buf) - w.Write([]bulkPqRow{ - {Geometry: []byte{1, 2, 3}, ID: "bus_42", TS: "2026-02-26T10:00:00Z"}, - {Geometry: []byte{4, 5, 6}, ID: "bus_57", TS: "2026-02-26T10:01:00Z"}, - }) - w.Close() - obs, err := parseGeoParquet(buf.Bytes()) - if err != nil { - t.Fatal(err) - } - if len(obs) != 2 || obs[0].id != "bus_42" || obs[1].t != "2026-02-26T10:01:00Z" { - t.Fatalf("parsed parquet wrong: %+v", obs) - } - if obs[0].wkb == nil { - t.Fatal("parquet observation should carry WKB") - } -} diff --git a/collections_test.go b/collections_test.go deleted file mode 100644 index c9638f4..0000000 --- a/collections_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import "testing" - -// crsCode extracts an EPSG code from the crs field of a Collection body in any -// of its accepted forms, defaulting to 4326. -func TestCrsCode(t *testing.T) { - cases := map[string]int{ - `["http://www.opengis.net/def/crs/EPSG/0/25832"]`: 25832, - `"urn:ogc:def:crs:EPSG::3812"`: 3812, - `4326`: 4326, - `"4258"`: 4258, - `null`: 4326, // absent -> default CRS84 - `"not a crs"`: 4326, - } - for in, want := range cases { - if got := crsCode([]byte(in)); got != want { - t.Errorf("crsCode(%s) = %d, want %d", in, got, want) - } - } -} - -// validID guards the collection id interpolated into CREATE/DROP TABLE. -func TestValidID(t *testing.T) { - good := []string{"ships", "drones", "fleet_2026", "_tmp", "a"} - bad := []string{"", "Ships", "bad-name", "drop table", "f leet", "2026fleet", "x;y", `a"b`} - for _, s := range good { - if !validID.MatchString(s) { - t.Errorf("validID rejected a valid id %q", s) - } - } - for _, s := range bad { - if validID.MatchString(s) { - t.Errorf("validID accepted an unsafe id %q", s) - } - } -} - -// propsExpr / featCols switch the SQL between the generic JSONB properties -// column and the typed ships columns. -func TestSchemaMode(t *testing.T) { - if propsExpr(true) != "coalesce(properties,'{}'::jsonb)" { - t.Errorf("generic propsExpr wrong: %s", propsExpr(true)) - } - if propsExpr(false) != "jsonb_build_object('mmsi',mmsi,'name',name)" { - t.Errorf("typed propsExpr wrong: %s", propsExpr(false)) - } - if featCols(true) != "id, properties" || featCols(false) != "id, mmsi, name" { - t.Errorf("featCols wrong: %q / %q", featCols(true), featCols(false)) - } -} - -// propsJSON serialises a feature's properties for the generic jsonb column. -func TestPropsJSON(t *testing.T) { - if got := propsJSON(nil); got != "{}" { - t.Errorf("propsJSON(nil) = %q, want {}", got) - } - if got := propsJSON(map[string]any{"model": "X500"}); got != `{"model":"X500"}` { - t.Errorf("propsJSON = %q", got) - } -} diff --git a/docs/mf-api-comparison.md b/docs/mf-api-comparison.md deleted file mode 100644 index f114f93..0000000 --- a/docs/mf-api-comparison.md +++ /dev/null @@ -1,84 +0,0 @@ -# MobilityAPI, the mf-api reference, and the OGC standard - -MobilityAPI implements [OGC API – Moving Features – Part 1: Core](https://docs.ogc.org/is/22-003r3/22-003r3.html) -over MobilityDB. This note compares its surface to the standard and to the -reference implementation [`aistairc/mf-api`](https://github.com/aistairc/mf-api), -a Python server built on pygeoapi and MobilityDB. - -## Stacks - -| | MobilityAPI | aistairc/mf-api | -|---|---|---| -| Language | Go (`net/http`, `pgxpool`) | Python (Flask via pygeoapi) | -| Temporal engine | MobilityDB (all temporal work in SQL; the tier holds no MEOS) | MobilityDB + PyMEOS / SQLAlchemy / GeoAlchemy2 | -| Geometry | PostGIS | PostGIS | -| Response model | streamed, keyset-paged FeatureCollection | in-memory FeatureCollection | - -## Endpoints - -Each row is one OGC API – Moving Features resource. The standard and the mf-api -reference share the same route set, since mf-api is the reference implementation -of Part 1: Core. - -| Method · Path | OGC Part 1 | mf-api | MobilityAPI | -|---|:--:|:--:|:--:| -| `GET /` | ● | ● | ● | -| `GET /api` | ● | ● | ● | -| `GET /conformance` | ● | ● | ● | -| `GET /collections` | ● | ● | ● | -| `POST /collections` | ● | ● | ● | -| `GET /collections/{c}` | ● | ● | ● | -| `PUT /collections/{c}` | ● | ● | ● | -| `DELETE /collections/{c}` | ● | ● | ● | -| `GET /collections/{c}/items` | ● | ● | ● | -| `POST /collections/{c}/items` | ● | ● | ● | -| `GET /collections/{c}/items/{f}` | ● | ● | ● | -| `DELETE /collections/{c}/items/{f}` | ● | ● | ● | -| `GET …/{f}/tgsequence` | ● | ● | ● | -| `POST …/{f}/tgsequence` | ● | ● | ● | -| `DELETE …/tgsequence/{tg}` | ● | ● | ● ¹ | -| `GET …/tgsequence/{tg}/distance` | ● | ● | ● | -| `GET …/tgsequence/{tg}/velocity` | ● | ● | ● | -| `GET …/tgsequence/{tg}/acceleration` | ● | ● | 501 ² | -| `GET …/{f}/tproperties` | ● | ● | ● | -| `POST …/{f}/tproperties` | ● | ● | ● | -| `GET …/tproperties/{name}` | ● | ● | ● | -| `POST …/tproperties/{name}` | ● | ● | ● | -| `DELETE …/tproperties/{name}` | ● | ● | ● | - -Beyond the standard, MobilityAPI adds `GET /health`, `GET …/{f}` exposing the -single-feature GeoJSON geometry, `PUT …/items/{f}`, and a lakehouse bulk feed -`GET …/{c}/export` (NDJSON, or `?format=parquet`). These sit outside `conformsTo`. - -¹ A feature carries a single temporal geometry, so `DELETE …/tgsequence/{tg}` -returns 501 with the guidance to delete the feature instead. - -² Acceleration returns 501. With linearly interpolated position the speed is -piecewise-constant, so its derivative is zero within each segment and undefined -at the vertices; the value is not approximated. - -## Temporal properties - -The standard distinguishes two families that the route tree keeps separate: - -- **TemporalGeometryQuery** — `…/tgsequence/{tg}/distance|velocity|acceleration` - — kinematics **derived** from the geometry. MobilityAPI serves distance and - velocity from the exact MobilityDB functions `cumulativeLength` and `speed`, - reporting each segment's own interpolation; acceleration is 501 (note ²). -- **TemporalProperty** — `…/tproperties` — **user-supplied**, time-varying - non-spatial attributes (fuel, temperature, load). MobilityAPI stores each as a - native MobilityDB temporal value (`tfloat`, `tint`, `ttext`, `tbool`) keyed by - collection, feature and name; `POST` parses the value with the type-specific - `*FromMFJSON`, appending merges new values and rejects a temporal overlap with - 409, and `GET` serves an OGC `temporalProperty` via `asMFJSON`, honouring the - `datetime` and `leaf` selectors. - -## Conformance - -``` -http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/common -http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/mf-collection -http://www.opengis.net/spec/ogcapi-movingfeatures-1/1.0/conf/movingfeatures -http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core -http://www.opengis.net/spec/ogcapi-common-2/1.0/conf/collections -``` diff --git a/go.mod b/go.mod index 99aab8d..ad89dd0 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,16 @@ module github.com/estebanzimanyi/MobilityAPI-go go 1.25.0 require ( - github.com/andybalholm/brotli v1.1.1 - github.com/jackc/pgx/v5 v5.9.2 - github.com/klauspost/compress v1.17.9 - github.com/parquet-go/parquet-go v0.30.1 -) - -require ( + github.com/andybalholm/brotli v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/parquet-go/bitpack v1.0.0 // indirect github.com/parquet-go/jsonlite v1.0.0 // indirect + github.com/parquet-go/parquet-go v0.30.1 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/twpayne/go-geom v1.6.1 // indirect golang.org/x/sync v0.17.0 // indirect diff --git a/go.sum b/go.sum index db55728..ad553cd 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,8 @@ -github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= -github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= -github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -33,16 +21,12 @@ github.com/parquet-go/parquet-go v0.30.1 h1:Oy6ganNrAdFiVwy7wNmWagfPTWA2X9Z3tVHB github.com/parquet-go/parquet-go v0.30.1/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= @@ -50,11 +34,7 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index fff3917..f88859a 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "log" "net/http" "net/url" @@ -30,7 +29,6 @@ import ( "time" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/parquet-go/parquet-go" ) @@ -89,16 +87,11 @@ func main() { mux.HandleFunc("GET /api", apiDoc) mux.HandleFunc("GET /conformance", conformance) mux.HandleFunc("GET /collections", listCollections) - mux.HandleFunc("POST /collections", postCollection) mux.HandleFunc("GET /collections/{cid}", getCollection) - mux.HandleFunc("PUT /collections/{cid}", putCollection) - mux.HandleFunc("DELETE /collections/{cid}", deleteCollection) mux.HandleFunc("GET /collections/{cid}/items", streamItems) mux.HandleFunc("GET /collections/{cid}/items/{fid}", getItem) mux.HandleFunc("POST /collections/{cid}/items", postItem) mux.HandleFunc("GET /collections/{cid}/export", export) // lakehouse bulk feed (NDJSON | Parquet) - // extension (not in conformsTo): bulk ingest of a real-time fleet feed - mux.HandleFunc("POST /collections/{cid}/bulk", bulkIngest) // OGC API – Moving Features sub-resources of a moving feature: mux.HandleFunc("GET /collections/{cid}/items/{fid}/tgsequence", tgSequence) mux.HandleFunc("GET /collections/{cid}/items/{fid}/tgsequence/{tgid}/{qtype}", tgSequenceQuery) @@ -108,10 +101,10 @@ func main() { mux.HandleFunc("PUT /collections/{cid}/items/{fid}", putItem) mux.HandleFunc("DELETE /collections/{cid}/items/{fid}", deleteItem) mux.HandleFunc("POST /collections/{cid}/items/{fid}/tgsequence", postTgSequence) - // temporal properties are user-supplied, stored, time-varying attributes - mux.HandleFunc("POST /collections/{cid}/items/{fid}/tproperties", postTProperties) - mux.HandleFunc("POST /collections/{cid}/items/{fid}/tproperties/{pname}", postTPropertyValues) - mux.HandleFunc("DELETE /collections/{cid}/items/{fid}/tproperties/{pname}", deleteTProperty) + // derived properties are computed from the geometry, not independently stored + mux.HandleFunc("POST /collections/{cid}/items/{fid}/tproperties", derivedReadOnly) + mux.HandleFunc("POST /collections/{cid}/items/{fid}/tproperties/{pname}", derivedReadOnly) + mux.HandleFunc("DELETE /collections/{cid}/items/{fid}/tproperties/{pname}", derivedReadOnly) mux.HandleFunc("DELETE /collections/{cid}/items/{fid}/tgsequence/{tgid}", deleteTgSequence) addr := ":" + strconv.Itoa(envInt("MFAPI_PORT", 8088)) @@ -152,48 +145,15 @@ func collectionMeta(ctx context.Context, cid string) (table string, srid int, ok err := pool.QueryRow(ctx, `SELECT id, crs FROM collections WHERE id=$1`, cid).Scan(&table, &srid) return table, srid, err == nil } - -// collectionGeneric reports whether a collection's feature table carries a -// generic `properties jsonb` column (collections created through POST -// /collections) rather than the typed ships columns (mmsi, name). -func collectionGeneric(ctx context.Context, table string) bool { - var g bool - pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM information_schema.columns - WHERE table_schema='public' AND table_name=$1 AND column_name='properties')`, table).Scan(&g) - return g -} - -// propsExpr is the SQL producing a feature's 'properties' JSON: the stored -// JSONB for generic collections, the typed (mmsi, name) object for ships. -func propsExpr(generic bool) string { - if generic { - return "coalesce(properties,'{}'::jsonb)" - } - return "jsonb_build_object('mmsi',mmsi,'name',name)" -} - -// featCols lists the non-geometry feature columns carried through the inner -// selects so propsExpr can read them. -func featCols(generic bool) string { - if generic { - return "id, properties" - } - return "id, mmsi, name" -} - func ident(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } -// validID guards identifiers interpolated into CREATE/DROP TABLE (which cannot -// be parameterised): a collection id must be a plain SQL identifier. -var validID = regexp.MustCompile(`^[a-z_][a-z0-9_]*$`) - // featureObj builds the OGC MovingFeature JSON for a row exposing columns id, // mmsi, name plus a geometry g and its STBOX b. sp and cp are the SQL parameter // placeholders for the collection SRID and id. crs is emitted in EPSG:n form and // rewritten to the OGC URN by ogcify; bbox/time come from the inline STBOX. -func featureObj(g, b, sp, cp, pe string, withGeom bool) string { +func featureObj(g, b, sp, cp string, withGeom bool) string { s := "jsonb_build_object('type','Feature','id',id::text," + - "'properties'," + pe + "," + + "'properties',jsonb_build_object('mmsi',mmsi,'name',name)," + "'crs',jsonb_build_object('type','Name','properties',jsonb_build_object('name','EPSG:'||" + sp + "::text))," + "'trs',jsonb_build_object('type','Link','properties',jsonb_build_object('type','ogcdef','href','http://www.opengis.net/def/uom/ISO-8601/0/Gregorian'))," + "'bbox',jsonb_build_array(Xmin(" + b + "),Ymin(" + b + "),Xmax(" + b + "),Ymax(" + b + "))," + @@ -277,147 +237,6 @@ func getCollection(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, col) } -// epsgURI matches the EPSG code in an OGC CRS URI (.../def/crs/EPSG/0/). -var epsgURI = regexp.MustCompile(`EPSG/\d+/(\d+)`) - -// crsCode extracts an EPSG code from a collection body's crs field (a URI, a -// URI array, or a bare integer); it defaults to 4326 (CRS84) when absent. -func crsCode(raw json.RawMessage) int { - s := string(raw) - if m := epsgURI.FindStringSubmatch(s); m != nil { - n, _ := strconv.Atoi(m[1]) - return n - } - if m := epsgURN.FindStringSubmatch(s); m != nil { - n, _ := strconv.Atoi(m[1]) - return n - } - if n, err := strconv.Atoi(strings.Trim(s, " \t\n\"")); err == nil { - return n - } - return 4326 -} - -// postCollection registers a new collection: it creates the feature table with -// the generic (id, properties, trip) schema and a GiST index, then records the -// collection metadata in the registry the tier reads. -func postCollection(w http.ResponseWriter, r *http.Request) { - var c struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - ItemType string `json:"itemType"` - CRS json.RawMessage `json:"crs"` - } - if err := json.NewDecoder(r.Body).Decode(&c); err != nil || c.ID == "" { - httpErr(w, 400, "invalid Collection / missing id") - return - } - if !validID.MatchString(c.ID) { - httpErr(w, 400, "collection id must match [a-z_][a-z0-9_]* (a plain SQL identifier)") - return - } - if _, _, exists := collectionMeta(r.Context(), c.ID); exists { - httpErr(w, 409, "collection already exists") - return - } - if c.ItemType == "" { - c.ItemType = "movingfeature" - } - srid := crsCode(c.CRS) - tx, err := pool.Begin(r.Context()) - if err != nil { - httpErr(w, 500, err.Error()) - return - } - defer tx.Rollback(r.Context()) - if _, err := tx.Exec(r.Context(), - `CREATE TABLE IF NOT EXISTS collections (id text PRIMARY KEY, title text, - description text, item_type text, crs integer)`); err != nil { - httpErr(w, 500, err.Error()) - return - } - if _, err := tx.Exec(r.Context(), - "CREATE TABLE "+ident(c.ID)+" (id integer PRIMARY KEY, properties jsonb, trip tgeompoint)"); err != nil { - httpErr(w, 400, "create collection failed: "+err.Error()) - return - } - if _, err := tx.Exec(r.Context(), - "CREATE INDEX ON "+ident(c.ID)+" USING gist (trip)"); err != nil { - httpErr(w, 500, err.Error()) - return - } - if _, err := tx.Exec(r.Context(), - `INSERT INTO collections (id,title,description,item_type,crs) VALUES ($1,$2,$3,$4,$5)`, - c.ID, c.Title, c.Description, c.ItemType, srid); err != nil { - httpErr(w, 400, "register collection failed: "+err.Error()) - return - } - if err := tx.Commit(r.Context()); err != nil { - httpErr(w, 500, err.Error()) - return - } - w.Header().Set("Location", "/collections/"+c.ID) - writeJSON(w, 201, map[string]any{"message": "created", "id": c.ID}) -} - -// putCollection replaces a collection's metadata (title, description, crs). -func putCollection(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - var c struct { - Title string `json:"title"` - Description string `json:"description"` - CRS json.RawMessage `json:"crs"` - } - if err := json.NewDecoder(r.Body).Decode(&c); err != nil { - httpErr(w, 400, "invalid Collection body") - return - } - ct, err := pool.Exec(r.Context(), - `UPDATE collections SET title=$2, description=$3, crs=$4 WHERE id=$1`, - cid, c.Title, c.Description, crsCode(c.CRS)) - if err != nil { - httpErr(w, 400, "update failed: "+err.Error()) - return - } - if ct.RowsAffected() == 0 { - httpErr(w, 404, "collection not found") - return - } - writeJSON(w, 200, map[string]any{"message": "replaced", "id": cid}) -} - -// deleteCollection removes a registered collection: it drops the feature table -// and deletes the registry row in one transaction. -func deleteCollection(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - tbl, _, ok := collectionMeta(r.Context(), cid) - if !ok { - httpErr(w, 404, "collection not found") - return - } - tx, err := pool.Begin(r.Context()) - if err != nil { - httpErr(w, 500, err.Error()) - return - } - defer tx.Rollback(r.Context()) - if _, err := tx.Exec(r.Context(), "DROP TABLE IF EXISTS "+ident(tbl)); err != nil { - httpErr(w, 500, err.Error()) - return - } - if _, err := tx.Exec(r.Context(), "DELETE FROM collections WHERE id=$1", cid); err != nil { - httpErr(w, 500, err.Error()) - return - } - if err := tx.Commit(r.Context()); err != nil { - httpErr(w, 500, err.Error()) - return - } - purgeTProps(r.Context(), "cid=$1", cid) - w.WriteHeader(204) -} - // itemFilters builds the index-using WHERE clause and the temporalGeometry // expression (clipped when subTrajectory=true) from the OGC query parameters. func itemFilters(tbl string, srid int, q map[string][]string) (where string, tgExpr string, args []any, err error) { @@ -510,11 +329,9 @@ func streamItems(w http.ResponseWriter, r *http.Request) { sp := "$" + itoa(len(args)) args = append(args, r.PathValue("cid")) cp := "$" + itoa(len(args)) - generic := collectionGeneric(r.Context(), tbl) - fc, pe := featCols(generic), propsExpr(generic) - sql := "SELECT id, " + featureObj("g", "b", sp, cp, pe, false) + "::text FROM (" + - "SELECT " + fc + ", g, stbox(g) AS b FROM (" + - "SELECT " + fc + ", " + tgExpr + " AS g FROM " + ident(tbl) + " " + where + + sql := "SELECT id, " + featureObj("g", "b", sp, cp, false) + "::text FROM (" + + "SELECT id, mmsi, name, g, stbox(g) AS b FROM (" + + "SELECT id, mmsi, name, " + tgExpr + " AS g FROM " + ident(tbl) + " " + where + " ORDER BY id LIMIT " + lp + ") i) s ORDER BY id" rows, err := pool.Query(r.Context(), sql, args...) if err != nil { @@ -566,11 +383,9 @@ func getItem(w http.ResponseWriter, r *http.Request) { httpErr(w, 400, "invalid feature id") return } - generic := collectionGeneric(r.Context(), tbl) - fc, pe := featCols(generic), propsExpr(generic) - sql := "SELECT " + featureObj("g", "b", "$2", "$3", pe, true) + "::text FROM (" + - "SELECT " + fc + ", g, stbox(g) AS b FROM (" + - "SELECT " + fc + ", trip AS g FROM " + ident(tbl) + " WHERE id=$1) i) s" + sql := "SELECT " + featureObj("g", "b", "$2", "$3", true) + "::text FROM (" + + "SELECT id, mmsi, name, g, stbox(g) AS b FROM (" + + "SELECT id, mmsi, name, trip AS g FROM " + ident(tbl) + " WHERE id=$1) i) s" var body string if err := pool.QueryRow(r.Context(), sql, fid, itoa(srid), r.PathValue("cid")).Scan(&body); err != nil { httpErr(w, 404, "feature not found") @@ -588,99 +403,11 @@ type propSpec struct{ expr, uom, desc string } var tProps = map[string]propSpec{ "velocity": {"speed(trip)", "m/s", "Speed over ground (velocity magnitude), a piecewise-constant function of the trajectory."}, + "speed": {"speed(trip)", "m/s", "Speed over ground, a piecewise-constant function of the trajectory."}, "distance": {"cumulativeLength(trip)", "m", "Cumulative distance travelled along the trajectory."}, + "heading": {"azimuth(trip)", "rad", "Heading (azimuth) over ground in radians, a piecewise-constant function of the trajectory."}, } - -// tType describes how a scalar temporal property is carried: mf is the -// MobilityDB MF-JSON moving-type token, col its storage column, cast its type, -// ogc the canonical OGC type token, and defInterp the interpolation MobilityDB -// assumes when the body omits one. -type tType struct{ mf, col, cast, ogc, defInterp string } - -// tPropType resolves an OGC temporal property type token to the four scalar -// temporal types MobilityDB carries as time-varying attribute values. -func tPropType(t string) (tType, bool) { - switch strings.ToLower(strings.TrimSpace(t)) { - case "", "treal", "tfloat", "measure", "real", "float", "double", "number": - return tType{"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, true - case "tint", "tinteger", "integer", "int": - return tType{"MovingInteger", "vint", "tint", "TInt", "Step"}, true - case "ttext", "tstring", "text", "string": - return tType{"MovingText", "vtext", "ttext", "TText", "Discrete"}, true - case "tbool", "tboolean", "boolean", "bool": - return tType{"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, true - } - return tType{}, false -} - -// orTrue returns the JSON boolean v, or true when v is absent — MF-JSON sequence -// bounds default to inclusive. -func orTrue(v any) bool { - if b, ok := v.(bool); ok { - return b - } - return true -} - -// tPropMFJSON renders a MobilityDB MF-JSON document for one temporal property -// from its OGC body: either a flat {datetimes, values, interpolation} object or -// a valueSequence array (a sequence set when it holds more than one segment). -// The interpolation token is mapped from OGC to MobilityDB and defaults per type. -func tPropMFJSON(mfType, defInterp string, body map[string]any) (string, error) { - mapInterp := func(v any) string { - s, _ := v.(string) - if s == "" { - return defInterp - } - if m, ok := ogc2mdbInterp[s]; ok { - return m - } - return s - } - if vs, ok := body["valueSequence"].([]any); ok && len(vs) > 0 { - if len(vs) == 1 { - seq, _ := vs[0].(map[string]any) - return tPropMFJSON(mfType, defInterp, seq) - } - seqs := make([]any, 0, len(vs)) - interp := "" - for _, s := range vs { - m, _ := s.(map[string]any) - if interp == "" { - interp = mapInterp(m["interpolation"]) - } - seqs = append(seqs, map[string]any{ - "values": m["values"], "datetimes": m["datetimes"], - "lower_inc": orTrue(m["lower_inc"]), "upper_inc": orTrue(m["upper_inc"]), - }) - } - b, _ := json.Marshal(map[string]any{"type": mfType, "sequences": seqs, "interpolation": interp}) - return string(b), nil - } - if body["datetimes"] == nil || body["values"] == nil { - return "", errors.New("temporal property requires datetimes and values (or a valueSequence)") - } - b, _ := json.Marshal(map[string]any{ - "type": mfType, "datetimes": body["datetimes"], "values": body["values"], - "interpolation": mapInterp(body["interpolation"]), - "lower_inc": orTrue(body["lower_inc"]), "upper_inc": orTrue(body["upper_inc"]), - }) - return string(b), nil -} - -// ensureTPropTable creates the shared temporal-property store on first write: a -// row per (collection, feature, property) holding the value as a native -// MobilityDB temporal value in the column matching its type. -func ensureTPropTable(ctx context.Context, q interface { - Exec(context.Context, string, ...any) (pgconn.CommandTag, error) -}) error { - _, err := q.Exec(ctx, `CREATE TABLE IF NOT EXISTS mf_tproperty ( - cid text NOT NULL, fid bigint NOT NULL, name text NOT NULL, - ptype text NOT NULL, uom text, description text, - vfloat tfloat, vint tint, vtext ttext, vbool tbool, - PRIMARY KEY (cid, fid, name))`) - return err -} +var tPropList = []string{"velocity", "distance", "heading"} // clip wraps a temporal expression with atTime for the OGC leaf (instant set) // or datetime (interval) selector, binding the selector value as a parameter. @@ -761,67 +488,43 @@ func tgSequenceQuery(w http.ResponseWriter, r *http.Request) { writeTemporalProperty(w, r, tbl, q, tProps[q]) } -// getTProperty serves one stored temporal property as an OGC temporalProperty, -// optionally clipped to a datetime interval or to leaf instants. +// getTProperty serves one derived temporal property as an OGC temporalProperty. func getTProperty(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - name := r.PathValue("pname") - if _, _, ok := collectionMeta(r.Context(), cid); !ok { + name := strings.ToLower(r.PathValue("pname")) + spec, ok := tProps[name] + if !ok { + httpErr(w, 404, "unknown temporal property: "+name) + return + } + tbl, _, ok := collectionMeta(r.Context(), r.PathValue("cid")) + if !ok { httpErr(w, 404, "collection not found") return } + writeTemporalProperty(w, r, tbl, name, spec) +} + +// writeTemporalProperty builds the OGC temporalProperty object in SQL: the +// derived tfloat is serialised with asMFJSON and reshaped into a valueSequence, +// carrying each segment's own interpolation verbatim from MobilityDB. +func writeTemporalProperty(w http.ResponseWriter, r *http.Request, tbl, name string, spec propSpec) { fid, err := strconv.Atoi(r.PathValue("fid")) if err != nil { httpErr(w, 400, "invalid feature id") return } - var ptype, uom, desc string - err = pool.QueryRow(r.Context(), - "SELECT ptype, coalesce(uom,''), coalesce(description,'') FROM mf_tproperty WHERE cid=$1 AND fid=$2 AND name=$3", - cid, fid, name).Scan(&ptype, &uom, &desc) - if errors.Is(err, pgx.ErrNoRows) { - httpErr(w, 404, "unknown temporal property: "+name) - return - } - if err != nil { - httpErr(w, 500, err.Error()) - return - } - tt, ok := tPropType(ptype) - if !ok { - httpErr(w, 500, "stored property has an unknown type: "+ptype) - return - } - expr, args, cerr := clip(tt.col, r.URL.Query(), []any{cid, fid, name, uom, desc}) + expr, args, cerr := clip(spec.expr, r.URL.Query(), []any{fid, name, spec.uom, spec.desc}) if cerr != nil { httpErr(w, 400, cerr.Error()) return } args = append(args, r.URL.Path) selfP := "$" + itoa(len(args)) - sql := "WITH base AS (SELECT asMFJSON(" + expr + ")::jsonb AS j FROM mf_tproperty WHERE cid=$1 AND fid=$2 AND name=$3) " + - tPropertyReshape(tt.ogc, "$3", "$4", "$5", selfP) - var body *string - if err := pool.QueryRow(r.Context(), sql, args...).Scan(&body); err != nil { - httpErr(w, 400, err.Error()) - return - } - if body == nil { - httpErr(w, 404, "no values for the requested selector") - return - } - writeRaw(w, 200, ogcify(*body)) -} - -// tPropertyReshape is the SELECT that turns a base CTE exposing j = the value's -// asMFJSON into an OGC temporalProperty. asMFJSON emits continuous values under -// "sequences" and discrete values (instants / leaf selection) as a single -// top-level values/datetimes object; both are reshaped into the OGC -// valueSequence, keeping each segment's interpolation verbatim. typ, np, fp, dp -// and sp are the type token and the $-placeholders for name, form, description -// and the self href. -func tPropertyReshape(typ, np, fp, dp, sp string) string { - return "SELECT jsonb_build_object('name'," + np + "::text,'type','" + typ + "','form'," + fp + "::text,'description'," + dp + "::text," + + // asMFJSON emits continuous values under "sequences" and discrete values + // (instants / leaf selection) as a single top-level values/datetimes object; + // reshape both into the OGC valueSequence, keeping the true interpolation. + sql := "WITH base AS (SELECT asMFJSON(" + expr + ")::jsonb AS j FROM " + ident(tbl) + " WHERE id=$1) " + + "SELECT jsonb_build_object('name',$2::text,'type','TReal','form',$3::text,'description',$4::text," + "'valueSequence', CASE" + " WHEN j ? 'sequences' THEN coalesce((SELECT jsonb_agg(jsonb_build_object(" + "'datetimes',seq->'datetimes','values',seq->'values','interpolation',j->>'interpolation'," + @@ -831,27 +534,7 @@ func tPropertyReshape(typ, np, fp, dp, sp string) string { "'datetimes',j->'datetimes','values',j->'values','interpolation',j->>'interpolation'," + "'lower_inc',j->'lower_inc','upper_inc',j->'upper_inc'))" + " ELSE '[]'::jsonb END," + - "'links',jsonb_build_array(jsonb_build_object('rel','self','href'," + sp + "::text)))::text FROM base" -} - -// writeTemporalProperty builds the OGC temporalProperty object in SQL for a -// derived measure: the tfloat is serialised with asMFJSON and reshaped into a -// valueSequence, carrying each segment's own interpolation verbatim from MobilityDB. -func writeTemporalProperty(w http.ResponseWriter, r *http.Request, tbl, name string, spec propSpec) { - fid, err := strconv.Atoi(r.PathValue("fid")) - if err != nil { - httpErr(w, 400, "invalid feature id") - return - } - expr, args, cerr := clip(spec.expr, r.URL.Query(), []any{fid, name, spec.uom, spec.desc}) - if cerr != nil { - httpErr(w, 400, cerr.Error()) - return - } - args = append(args, r.URL.Path) - selfP := "$" + itoa(len(args)) - sql := "WITH base AS (SELECT asMFJSON(" + expr + ")::jsonb AS j FROM " + ident(tbl) + " WHERE id=$1) " + - tPropertyReshape("TReal", "$2", "$3", "$4", selfP) + "'links',jsonb_build_array(jsonb_build_object('rel','self','href'," + selfP + "::text)))::text FROM base" var body string err = pool.QueryRow(r.Context(), sql, args...).Scan(&body) if errors.Is(err, pgx.ErrNoRows) { @@ -865,10 +548,9 @@ func writeTemporalProperty(w http.ResponseWriter, r *http.Request, tbl, name str writeRaw(w, 200, ogcify(body)) } -// listTProperties lists the stored temporal properties of a feature. +// listTProperties lists the derived temporal properties available for a feature. func listTProperties(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - tbl, _, ok := collectionMeta(r.Context(), cid) + tbl, _, ok := collectionMeta(r.Context(), r.PathValue("cid")) if !ok { httpErr(w, 404, "collection not found") return @@ -884,26 +566,11 @@ func listTProperties(w http.ResponseWriter, r *http.Request) { return } base := r.URL.Path - list := make([]map[string]any, 0) - var reg *string - pool.QueryRow(r.Context(), "SELECT to_regclass('mf_tproperty')").Scan(®) - if reg != nil { - rows, err := pool.Query(r.Context(), - "SELECT name, ptype, coalesce(uom,''), coalesce(description,'') FROM mf_tproperty WHERE cid=$1 AND fid=$2 ORDER BY name", - cid, fid) - if err != nil { - httpErr(w, 500, err.Error()) - return - } - defer rows.Close() - for rows.Next() { - var name, ptype, uom, desc string - if err := rows.Scan(&name, &ptype, &uom, &desc); err != nil { - break - } - list = append(list, map[string]any{"name": name, "type": ptype, "form": uom, "description": desc, - "links": []map[string]string{{"rel": "self", "href": base + "/" + name}}}) - } + list := make([]map[string]any, 0, len(tPropList)) + for _, n := range tPropList { + s := tProps[n] + list = append(list, map[string]any{"name": n, "type": "TReal", "form": s.uom, "description": s.desc, + "links": []map[string]string{{"rel": "self", "href": base + "/" + n}}}) } writeJSON(w, 200, map[string]any{ "temporalProperties": list, "numberReturned": len(list), "numberMatched": len(list), @@ -928,52 +595,25 @@ func tstzSet(csv string) (string, error) { // apiDoc serves a minimal OpenAPI definition (the OGC service-desc resource). func apiDoc(w http.ResponseWriter, r *http.Request) { - op := func(summary string) map[string]any { - return map[string]any{"summary": summary, "responses": map[string]any{"200": map[string]any{"description": "OK"}}} + get := func(summary string) map[string]any { + return map[string]any{"get": map[string]any{"summary": summary, + "responses": map[string]any{"200": map[string]any{"description": "OK"}}}} } - get := func(summary string) map[string]any { return map[string]any{"get": op(summary)} } doc := map[string]any{ "openapi": "3.0.3", "info": map[string]any{"title": "MobilityAPI-go", "version": "1.0.0", "description": "OGC API – Moving Features over MobilityDB"}, "paths": map[string]any{ - "/": get("Landing page"), - "/api": get("API definition"), - "/conformance": get("Conformance declaration"), - "/collections": map[string]any{ - "get": op("Moving feature collections"), - "post": op("Register a new collection"), - }, - "/collections/{cid}": map[string]any{ - "get": op("Collection metadata"), - "put": op("Replace collection metadata"), - "delete": op("Delete a collection"), - }, - "/collections/{cid}/items": map[string]any{ - "get": op("Moving features (streamed, keyset-paged; bbox/datetime/subtrajectory filters)"), - "post": op("Insert a moving feature"), - }, - "/collections/{cid}/items/{fid}": map[string]any{ - "get": op("A moving feature as a Feature"), - "put": op("Replace a moving feature"), - "delete": op("Delete a moving feature"), - }, - "/collections/{cid}/items/{fid}/tgsequence": map[string]any{ - "get": op("Temporal geometry sequence (MF-JSON)"), - "post": op("Append a temporally-disjoint sub-trajectory"), - "delete": op("501 — the feature carries a single temporal geometry; delete the feature instead"), - }, - "/collections/{cid}/items/{fid}/tgsequence/{tgid}/{qtype}": get("Temporal geometry derived query: distance | velocity (acceleration → 501, not derivable for this motion model)"), - "/collections/{cid}/items/{fid}/tproperties": map[string]any{ - "get": op("Stored temporal properties of a feature"), - "post": op("Add one or more temporal properties (TReal | TInt | TText | TBool) to a feature"), - }, - "/collections/{cid}/items/{fid}/tproperties/{pname}": map[string]any{ - "get": op("A stored temporal property as an OGC temporalProperty"), - "post": op("Append values to a temporal property (temporally disjoint; overlap → 409)"), - "delete": op("Delete a temporal property"), - }, - "/collections/{cid}/export": get("Bulk lakehouse export: NDJSON, or ?format=parquet (WKB + bbox/time sidecar)"), - "/collections/{cid}/bulk": map[string]any{"post": op("Bulk ingest (extension): a batch of (vehicleId, position, time) observations as GeoJSON Points or GeoParquet, optionally gzip/deflate/br/zstd-compressed; each is appended as one instant")}, + "/": get("Landing page"), + "/conformance": get("Conformance declaration"), + "/collections": get("Moving feature collections"), + "/collections/{cid}": get("Collection metadata"), + "/collections/{cid}/items": get("Moving features (streamed, keyset-paged; bbox/datetime/subtrajectory filters)"), + "/collections/{cid}/items/{fid}": get("A moving feature as a Feature"), + "/collections/{cid}/items/{fid}/tgsequence": get("Temporal geometry sequence (MF-JSON)"), + "/collections/{cid}/items/{fid}/tgsequence/{tgid}/{qtype}": get("Temporal geometry derived query: distance | velocity (acceleration is not derivable for this motion model)"), + "/collections/{cid}/items/{fid}/tproperties": get("Derived temporal properties of a feature"), + "/collections/{cid}/items/{fid}/tproperties/{pname}": get("A derived temporal property (velocity | distance | heading)"), + "/collections/{cid}/export": get("Bulk lakehouse export: NDJSON, or ?format=parquet (WKB + bbox/time sidecar)"), }, } w.Header().Set("Content-Type", "application/vnd.oai.openapi+json;version=3.0") @@ -1002,13 +642,12 @@ func export(w http.ResponseWriter, r *http.Request) { args = append(args, exportBatch) tail = " LIMIT $" + itoa(len(args)) } - generic := collectionGeneric(r.Context(), tbl) if r.URL.Query().Get("format") == "parquet" { - streamParquet(w, r, tbl, tgExpr, where, tail, args, generic) + streamParquet(w, r, tbl, tgExpr, where, tail, args) return } sql := "SELECT jsonb_build_object('type','Feature','id',id::text," + - "'properties'," + propsExpr(generic) + "," + + "'properties',jsonb_build_object('mmsi',mmsi,'name',name)," + "'temporalGeometry', asMFJSON(" + tgExpr + ")::jsonb)::text " + "FROM " + ident(tbl) + " " + where + " ORDER BY id" + tail rows, err := pool.Query(r.Context(), sql, args...) @@ -1049,72 +688,24 @@ type pqRow struct { Tmax string `parquet:"tmax"` } -// pqRowGeneric is the Parquet schema for collections with generic properties: -// the feature properties travel as one JSON column beside the WKB + sidecar. -type pqRowGeneric struct { - ID int64 `parquet:"id"` - Properties string `parquet:"properties"` - WKB []byte `parquet:"trajectory_wkb"` - Xmin float64 `parquet:"xmin"` - Ymin float64 `parquet:"ymin"` - Xmax float64 `parquet:"xmax"` - Ymax float64 `parquet:"ymax"` - Tmin string `parquet:"tmin"` - Tmax string `parquet:"tmax"` -} - // streamParquet writes Parquet from a server-side cursor, flushing a row group // every few thousand rows so memory stays bounded and each row group carries // its own min/max statistics for predicate pushdown. The trajectory geometry // materialises once per row (g) so the sidecar accessors do not re-clip. -func streamParquet(w http.ResponseWriter, r *http.Request, tbl, tgExpr, where, tail string, args []any, generic bool) { - sidecar := " asBinary(g), Xmin(stbox(g)), Ymin(stbox(g)), Xmax(stbox(g)), Ymax(stbox(g))," + +func streamParquet(w http.ResponseWriter, r *http.Request, tbl, tgExpr, where, tail string, args []any) { + sql := "SELECT id, mmsi, name, asBinary(g)," + + " Xmin(stbox(g)), Ymin(stbox(g)), Xmax(stbox(g)), Ymax(stbox(g))," + " Tmin(stbox(g))::text, Tmax(stbox(g))::text FROM (" + - "SELECT id, %s " + tgExpr + " AS g FROM " + ident(tbl) + " " + where + " ORDER BY id" + tail + ") s" - w.Header().Set("Content-Type", "application/vnd.apache.parquet") - w.Header().Set("Content-Disposition", `attachment; filename="`+tbl+`.parquet"`) - if generic { - sql := "SELECT id, props," + fmt.Sprintf(sidecar, "coalesce(properties,'{}'::jsonb)::text AS props,") - rows, err := pool.Query(r.Context(), sql, args...) - if err != nil { - httpErr(w, 500, err.Error()) - return - } - defer rows.Close() - pw := parquet.NewGenericWriter[pqRowGeneric](w) - defer pw.Close() - batch := make([]pqRowGeneric, 0, parquetRG) - emit := func() bool { - if len(batch) == 0 { - return true - } - if _, e := pw.Write(batch); e != nil { - return false - } - pw.Flush() - batch = batch[:0] - return true - } - for rows.Next() { - var x pqRowGeneric - if err := rows.Scan(&x.ID, &x.Properties, &x.WKB, &x.Xmin, &x.Ymin, &x.Xmax, &x.Ymax, &x.Tmin, &x.Tmax); err != nil { - break - } - batch = append(batch, x) - if len(batch) == parquetRG && !emit() { - break - } - } - emit() - return - } - sql := "SELECT id, mmsi, name," + fmt.Sprintf(sidecar, "coalesce(mmsi,0) AS mmsi, coalesce(name,'') AS name,") + "SELECT id, coalesce(mmsi,0) AS mmsi, coalesce(name,'') AS name, " + tgExpr + " AS g" + + " FROM " + ident(tbl) + " " + where + " ORDER BY id" + tail + ") s" rows, err := pool.Query(r.Context(), sql, args...) if err != nil { httpErr(w, 500, err.Error()) return } defer rows.Close() + w.Header().Set("Content-Type", "application/vnd.apache.parquet") + w.Header().Set("Content-Disposition", `attachment; filename="`+tbl+`.parquet"`) pw := parquet.NewGenericWriter[pqRow](w) defer pw.Close() batch := make([]pqRow, 0, parquetRG) @@ -1143,7 +734,7 @@ func streamParquet(w http.ResponseWriter, r *http.Request, tbl, tgExpr, where, t } func postItem(w http.ResponseWriter, r *http.Request) { - tbl, srid, ok := collectionMeta(r.Context(), r.PathValue("cid")) + tbl, _, ok := collectionMeta(r.Context(), r.PathValue("cid")) if !ok { httpErr(w, 404, "collection not found") return @@ -1164,24 +755,16 @@ func postItem(w http.ResponseWriter, r *http.Request) { } } tgBytes, _ := json.Marshal(feat.TG) - // default to the collection CRS; an explicit feature crs overrides it + srid := 25832 if m := epsgURN.FindStringSubmatch(string(feat.CRS)); m != nil { srid, _ = strconv.Atoi(m[1]) } id, _ := feat.ID.Int64() - var execErr error - if collectionGeneric(r.Context(), tbl) { - _, execErr = pool.Exec(r.Context(), - "INSERT INTO "+ident(tbl)+"(id,properties,trip) VALUES ($1,$2::jsonb, setSRID(tgeompointFromMFJSON($3), $4))", - id, propsJSON(feat.Properties), string(tgBytes), srid) - } else { - name, _ := feat.Properties["name"].(string) - _, execErr = pool.Exec(r.Context(), - "INSERT INTO "+ident(tbl)+"(id,mmsi,name,trip) VALUES ($1,$2,$3, setSRID(tgeompointFromMFJSON($4), $5))", - id, nil, name, string(tgBytes), srid) - } - if execErr != nil { - httpErr(w, 400, "ingest failed: "+execErr.Error()) + name, _ := feat.Properties["name"].(string) + if _, err := pool.Exec(r.Context(), + "INSERT INTO "+ident(tbl)+"(id,mmsi,name,trip) VALUES ($1,$2,$3, setSRID(tgeompointFromMFJSON($4), $5))", + id, nil, name, string(tgBytes), srid); err != nil { + httpErr(w, 400, "ingest failed: "+err.Error()) return } writeJSON(w, 201, map[string]any{"message": "created", "id": strconv.FormatInt(id, 10)}) @@ -1189,38 +772,28 @@ func postItem(w http.ResponseWriter, r *http.Request) { // featureTG decodes the temporalGeometry and name from a posted Feature (or a // bare temporalGeometry), mapping the OGC interpolation token to MobilityDB. -func featureTG(r *http.Request) (tgText string, props map[string]any, err error) { +func featureTG(r *http.Request) (tgText string, name string, err error) { var raw map[string]any if e := json.NewDecoder(r.Body).Decode(&raw); e != nil { - return "", nil, errors.New("invalid JSON body") + return "", "", errors.New("invalid JSON body") } tg := raw if inner, ok := raw["temporalGeometry"].(map[string]any); ok { tg = inner } if tg["type"] == nil { - return "", nil, errors.New("missing temporalGeometry") + return "", "", errors.New("missing temporalGeometry") } if interp, ok := tg["interpolation"].(string); ok { if m, ok := ogc2mdbInterp[interp]; ok { tg["interpolation"] = m } } - if p, ok := raw["properties"].(map[string]any); ok { - props = p + if props, ok := raw["properties"].(map[string]any); ok { + name, _ = props["name"].(string) } b, _ := json.Marshal(tg) - return string(b), props, nil -} - -// propsJSON serialises a feature's properties object for a generic collection's -// jsonb column ('{}' when absent). -func propsJSON(props map[string]any) string { - if props == nil { - return "{}" - } - b, _ := json.Marshal(props) - return string(b) + return string(b), name, nil } // putItem replaces a moving feature's temporal geometry (and name). @@ -1235,22 +808,14 @@ func putItem(w http.ResponseWriter, r *http.Request) { httpErr(w, 400, "invalid feature id") return } - tgText, props, derr := featureTG(r) + tgText, name, derr := featureTG(r) if derr != nil { httpErr(w, 400, derr.Error()) return } - var ct pgconn.CommandTag - if collectionGeneric(r.Context(), tbl) { - ct, err = pool.Exec(r.Context(), - "UPDATE "+ident(tbl)+" SET properties=$2::jsonb, trip=setSRID(tgeompointFromMFJSON($3), $4) WHERE id=$1", - fid, propsJSON(props), tgText, srid) - } else { - name, _ := props["name"].(string) - ct, err = pool.Exec(r.Context(), - "UPDATE "+ident(tbl)+" SET name=$2, trip=setSRID(tgeompointFromMFJSON($3), $4) WHERE id=$1", - fid, name, tgText, srid) - } + ct, err := pool.Exec(r.Context(), + "UPDATE "+ident(tbl)+" SET name=$2, trip=setSRID(tgeompointFromMFJSON($3), $4) WHERE id=$1", + fid, name, tgText, srid) if err != nil { httpErr(w, 400, "update failed: "+err.Error()) return @@ -1262,10 +827,9 @@ func putItem(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"message": "replaced", "id": strconv.Itoa(fid)}) } -// deleteItem removes a moving feature and any temporal properties stored on it. +// deleteItem removes a moving feature. func deleteItem(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - tbl, _, ok := collectionMeta(r.Context(), cid) + tbl, _, ok := collectionMeta(r.Context(), r.PathValue("cid")) if !ok { httpErr(w, 404, "collection not found") return @@ -1284,20 +848,9 @@ func deleteItem(w http.ResponseWriter, r *http.Request) { httpErr(w, 404, "feature not found") return } - purgeTProps(r.Context(), "cid=$1 AND fid=$2", cid, fid) w.WriteHeader(204) } -// purgeTProps deletes stored temporal properties matching the WHERE clause; it -// is a no-op when no property has ever been stored (the table is absent). -func purgeTProps(ctx context.Context, where string, args ...any) { - var reg *string - pool.QueryRow(ctx, "SELECT to_regclass('mf_tproperty')").Scan(®) - if reg != nil { - pool.Exec(ctx, "DELETE FROM mf_tproperty WHERE "+where, args...) - } -} - // postTgSequence appends a temporally-disjoint sub-trajectory to the feature's // temporal geometry. MobilityDB's merge rejects time overlap (mapped to 409). func postTgSequence(w http.ResponseWriter, r *http.Request) { @@ -1334,180 +887,10 @@ func postTgSequence(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"message": "appended", "id": strconv.Itoa(fid)}) } -// postTProperties registers one or more stored temporal properties on a feature -// (the body is a single temporalProperty object or an array of them). Each value -// is parsed by MobilityDB's type-specific *FromMFJSON and stored as a native -// temporal value, so it is queryable with the same operators as the trajectory. -func postTProperties(w http.ResponseWriter, r *http.Request) { - cid := r.PathValue("cid") - tbl, _, ok := collectionMeta(r.Context(), cid) - if !ok { - httpErr(w, 404, "collection not found") - return - } - fid, err := strconv.Atoi(r.PathValue("fid")) - if err != nil { - httpErr(w, 400, "invalid feature id") - return - } - var raw json.RawMessage - if e := json.NewDecoder(r.Body).Decode(&raw); e != nil { - httpErr(w, 400, "invalid JSON body") - return - } - var list []map[string]any - if e := json.Unmarshal(raw, &list); e != nil { - var one map[string]any - if e2 := json.Unmarshal(raw, &one); e2 != nil { - httpErr(w, 400, "invalid temporal property body") - return - } - list = []map[string]any{one} - } - if len(list) == 0 { - httpErr(w, 400, "no temporal property supplied") - return - } - tx, err := pool.Begin(r.Context()) - if err != nil { - httpErr(w, 500, err.Error()) - return - } - defer tx.Rollback(r.Context()) - var one int - if err := tx.QueryRow(r.Context(), "SELECT 1 FROM "+ident(tbl)+" WHERE id=$1", fid).Scan(&one); err != nil { - httpErr(w, 404, "feature not found") - return - } - if err := ensureTPropTable(r.Context(), tx); err != nil { - httpErr(w, 500, err.Error()) - return - } - names := make([]string, 0, len(list)) - for _, p := range list { - name, _ := p["name"].(string) - if name == "" { - httpErr(w, 400, "temporal property requires a name") - return - } - typeTok, _ := p["type"].(string) - tt, ok := tPropType(typeTok) - if !ok { - httpErr(w, 400, "unsupported temporal property type: "+typeTok) - return - } - uom := strOf(p["form"]) - if uom == "" { - uom = strOf(p["unitOfMeasure"]) - } - mfjson, perr := tPropMFJSON(tt.mf, tt.defInterp, p) - if perr != nil { - httpErr(w, 400, perr.Error()) - return - } - if _, e := tx.Exec(r.Context(), - "INSERT INTO mf_tproperty (cid,fid,name,ptype,uom,description,"+tt.col+") VALUES ($1,$2,$3,$4,$5,$6,"+tt.cast+"FromMFJSON($7))", - cid, fid, name, tt.ogc, uom, strOf(p["description"]), mfjson); e != nil { - if strings.Contains(e.Error(), "duplicate key") { - httpErr(w, 409, "temporal property already exists: "+name) - return - } - httpErr(w, 400, "store temporal property failed: "+e.Error()) - return - } - names = append(names, name) - } - if err := tx.Commit(r.Context()); err != nil { - httpErr(w, 500, err.Error()) - return - } - writeJSON(w, 201, map[string]any{"message": "created", "temporalProperties": names}) -} - -// postTPropertyValues appends more values to a stored temporal property; the new -// values must be temporally disjoint from the existing ones (overlap → 409). -func postTPropertyValues(w http.ResponseWriter, r *http.Request) { - cid, name := r.PathValue("cid"), r.PathValue("pname") - if _, _, ok := collectionMeta(r.Context(), cid); !ok { - httpErr(w, 404, "collection not found") - return - } - fid, err := strconv.Atoi(r.PathValue("fid")) - if err != nil { - httpErr(w, 400, "invalid feature id") - return - } - var ptype string - err = pool.QueryRow(r.Context(), "SELECT ptype FROM mf_tproperty WHERE cid=$1 AND fid=$2 AND name=$3", cid, fid, name).Scan(&ptype) - if errors.Is(err, pgx.ErrNoRows) { - httpErr(w, 404, "unknown temporal property: "+name) - return - } - if err != nil { - httpErr(w, 500, err.Error()) - return - } - tt, ok := tPropType(ptype) - if !ok { - httpErr(w, 500, "stored property has an unknown type: "+ptype) - return - } - var body map[string]any - if e := json.NewDecoder(r.Body).Decode(&body); e != nil { - httpErr(w, 400, "invalid JSON body") - return - } - mfjson, perr := tPropMFJSON(tt.mf, tt.defInterp, body) - if perr != nil { - httpErr(w, 400, perr.Error()) - return - } - ct, e := pool.Exec(r.Context(), - "UPDATE mf_tproperty SET "+tt.col+"=merge("+tt.col+", "+tt.cast+"FromMFJSON($4)) WHERE cid=$1 AND fid=$2 AND name=$3", - cid, fid, name, mfjson) - if e != nil { - if strings.Contains(e.Error(), "overlap") || strings.Contains(e.Error(), "common timestamp") { - httpErr(w, 409, "the new values overlap the existing ones in time: "+e.Error()) - return - } - httpErr(w, 400, "append failed: "+e.Error()) - return - } - if ct.RowsAffected() == 0 { - httpErr(w, 404, "unknown temporal property: "+name) - return - } - writeJSON(w, 200, map[string]any{"message": "appended", "name": name}) -} - -// deleteTProperty removes a stored temporal property from a feature. -func deleteTProperty(w http.ResponseWriter, r *http.Request) { - cid, name := r.PathValue("cid"), r.PathValue("pname") - if _, _, ok := collectionMeta(r.Context(), cid); !ok { - httpErr(w, 404, "collection not found") - return - } - fid, err := strconv.Atoi(r.PathValue("fid")) - if err != nil { - httpErr(w, 400, "invalid feature id") - return - } - var reg *string - pool.QueryRow(r.Context(), "SELECT to_regclass('mf_tproperty')").Scan(®) - if reg == nil { - httpErr(w, 404, "unknown temporal property: "+name) - return - } - ct, e := pool.Exec(r.Context(), "DELETE FROM mf_tproperty WHERE cid=$1 AND fid=$2 AND name=$3", cid, fid, name) - if e != nil { - httpErr(w, 500, e.Error()) - return - } - if ct.RowsAffected() == 0 { - httpErr(w, 404, "unknown temporal property: "+name) - return - } - w.WriteHeader(204) +// derivedReadOnly answers writes to derived temporal properties: they are +// computed from the trajectory, so they are modified through the geometry. +func derivedReadOnly(w http.ResponseWriter, r *http.Request) { + httpErr(w, 501, "temporal properties here (velocity, distance, heading) are derived from the trajectory and are not independently writable; modify the temporal geometry instead") } // deleteTgSequence: the feature carries a single inseparable temporal geometry. @@ -1517,7 +900,6 @@ func deleteTgSequence(w http.ResponseWriter, r *http.Request) { // small helpers func itoa(n int) string { return strconv.Itoa(n) } -func strOf(v any) string { s, _ := v.(string); return s } func first(q map[string][]string, k string) string { if v := q[k]; len(v) > 0 { return v[0] diff --git a/tproperties_test.go b/tproperties_test.go deleted file mode 100644 index 3832f9d..0000000 --- a/tproperties_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "encoding/json" - "testing" -) - -// tPropType resolves OGC / MobilityDB type tokens to the scalar temporal type -// carried in storage, defaulting an empty token to TReal. -func TestTPropType(t *testing.T) { - cases := map[string]tType{ - "": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, - "TReal": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, - "measure": {"MovingFloat", "vfloat", "tfloat", "TReal", "Linear"}, - "TInt": {"MovingInteger", "vint", "tint", "TInt", "Step"}, - "integer": {"MovingInteger", "vint", "tint", "TInt", "Step"}, - "TText": {"MovingText", "vtext", "ttext", "TText", "Discrete"}, - "string": {"MovingText", "vtext", "ttext", "TText", "Discrete"}, - "TBool": {"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, - "BOOLEAN ": {"MovingBoolean", "vbool", "tbool", "TBool", "Step"}, - } - for in, want := range cases { - got, ok := tPropType(in) - if !ok || got != want { - t.Errorf("tPropType(%q) = %+v,%v want %+v,true", in, got, ok, want) - } - } - if _, ok := tPropType("tgeompoint"); ok { - t.Error("tPropType accepted a non-scalar type") - } -} - -// tPropMFJSON renders a MobilityDB MF-JSON document from an OGC temporal -// property body, mapping interpolation and folding a valueSequence into a -// sequence set when it holds more than one segment. -func TestTPropMFJSON(t *testing.T) { - parse := func(s string) map[string]any { - var m map[string]any - if err := json.Unmarshal([]byte(s), &m); err != nil { - t.Fatalf("bad json: %v", err) - } - return m - } - unmarshal := func(s string) map[string]any { - var m map[string]any - json.Unmarshal([]byte(s), &m) - return m - } - - // flat form, OGC "Stepwise" maps to MobilityDB "Step" - out, err := tPropMFJSON("MovingFloat", "Linear", - parse(`{"datetimes":["2026-01-01T00:00:00Z"],"values":[1],"interpolation":"Stepwise"}`)) - if err != nil { - t.Fatal(err) - } - if m := unmarshal(out); m["type"] != "MovingFloat" || m["interpolation"] != "Step" { - t.Errorf("flat form wrong: %s", out) - } - - // absent interpolation falls back to the type default - out, _ = tPropMFJSON("MovingText", "Discrete", parse(`{"datetimes":["2026-01-01T00:00:00Z"],"values":["x"]}`)) - if m := unmarshal(out); m["interpolation"] != "Discrete" { - t.Errorf("default interpolation not applied: %s", out) - } - - // a single-segment valueSequence collapses to the flat form - out, _ = tPropMFJSON("MovingFloat", "Linear", - parse(`{"valueSequence":[{"datetimes":["2026-01-01T00:00:00Z"],"values":[1],"interpolation":"Linear"}]}`)) - if m := unmarshal(out); m["sequences"] != nil || m["datetimes"] == nil { - t.Errorf("single valueSequence should be flat: %s", out) - } - - // a multi-segment valueSequence becomes a sequence set - out, _ = tPropMFJSON("MovingFloat", "Linear", parse(`{"valueSequence":[ - {"datetimes":["2026-01-01T00:00:00Z"],"values":[1]}, - {"datetimes":["2026-01-02T00:00:00Z"],"values":[2]}]}`)) - if m := unmarshal(out); m["sequences"] == nil { - t.Errorf("multi valueSequence should be a sequence set: %s", out) - } - - // missing datetimes/values is an error - if _, err := tPropMFJSON("MovingFloat", "Linear", parse(`{"interpolation":"Linear"}`)); err == nil { - t.Error("expected an error for a body without datetimes/values") - } -} diff --git a/tutorial/README.md b/tutorial/README.md index 3d8922b..65f8098 100644 --- a/tutorial/README.md +++ b/tutorial/README.md @@ -7,12 +7,9 @@ notebook. ## Prerequisites -- The Go tier reachable on `http://localhost:8088` with the `ships` collection loaded - (the `mfapi_demo` database). Build and run it per the repository README - (`go build -o mfapi . && MFAPI_DSN= ./mfapi`). Point the notebook elsewhere with - the `MFAPI_HOST` environment variable. -- A Jupyter kernel. The notebook's first cell installs its Python packages - (`requests numpy matplotlib pyproj pillow`), so a bare kernel works. +- The Go tier running on `http://localhost:8088` with the `ships` collection loaded + (the `mfapi_demo` database). Start it with `./mfapi-go` (see the repo README). +- Python packages: `requests matplotlib numpy pyproj Pillow`. Build the `mfapi_demo` database from a day of [Danish Maritime Authority AIS data](https://web.ais.dk/aisdata/) with `setup/load_ships.sql`: @@ -23,14 +20,13 @@ psql -d mfapi_demo -v data_csv_path=/path/to/aisdk-2026-02-26.csv \ -f tutorial/setup/load_ships.sql ``` -The notebook is a plain-HTTP client — its second cell runs a preflight that checks the -tier is up and the `ships` collection is present, printing what to start if not, rather -than failing deep in a later cell. The basemap is fetched as OpenStreetMap XYZ tiles -(cached under `/tmp/mfapi_tiles`), so geopandas/contextily are not required. +The basemap is fetched as OpenStreetMap XYZ tiles (cached under `/tmp/mfapi_tiles`), so +geopandas/contextily are not required. ## Run ``` +pip install requests matplotlib numpy pyproj Pillow jupyter jupyter notebook tutorial/tutorial.ipynb ``` @@ -38,11 +34,8 @@ jupyter notebook tutorial/tutorial.ipynb Collections (with `crs`/`extent`/`links`), streamed and keyset-paged items, a single feature with the full standard fields (`crs`/`trs`/`bbox`/`time`/`geometry`), the `bbox` -and `subtrajectory`/`datetime` filters, and the temporal-geometry sequence. The derived -kinematics live under the temporal-geometry query (`tgsequence/{tg}/velocity` and -`/distance`, with `acceleration` returning `501` because it is not derivable for -piecewise-linear motion). The temporal properties are user-supplied, stored attributes: -the notebook adds a property, reads it back as an OGC `temporalProperty` (with the `leaf` -selector), appends values, and deletes it. It closes with the feature write lifecycle -(`POST`/`PUT`/`DELETE`, sub-trajectory append via `merge`) and the NDJSON/Parquet +and `subtrajectory`/`datetime` filters, the temporal-geometry sequence, the derived +measures (`velocity`/`distance`, and `acceleration` returning `501` because it is not +derivable for piecewise-linear motion), the temporal properties with the `leaf` selector, +the write lifecycle (`POST`/`PUT`/`DELETE`, append via `merge`), and the NDJSON/Parquet lakehouse export. diff --git a/tutorial/tutorial.ipynb b/tutorial/tutorial.ipynb index 2b915b1..503e0b5 100644 --- a/tutorial/tutorial.ipynb +++ b/tutorial/tutorial.ipynb @@ -1,15 +1,5 @@ { "cells": [ - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "# Install the notebook's Python dependencies (safe to re-run; needed on a fresh kernel / Colab).\n", - "%pip install -q requests numpy matplotlib pyproj pillow\n" - ] - }, { "cell_type": "markdown", "id": "a509b2c7", @@ -65,8 +55,8 @@ "from pyproj import Transformer\n", "from PIL import Image\n", "\n", - "HOST = os.environ.get(\"MFAPI_HOST\", \"http://localhost:8088\") # the Go MobilityAPI tier (override with MFAPI_HOST)\n", - "COLLECTION_ID = os.environ.get(\"MFAPI_COLLECTION\", \"ships\")\n", + "HOST = \"http://localhost:8088\" # the Go MobilityAPI tier\n", + "COLLECTION_ID = \"ships\"\n", "\n", "# the ships collection stores coordinates in EPSG:25832 (ETRS89 / UTM 32N, metres)\n", "_to3857 = Transformer.from_crs(25832, 3857, always_xy=True)" @@ -162,43 +152,6 @@ " plt.tight_layout(); plt.show()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Preflight — is the Go tier reachable?\n", - "\n", - "This notebook is a **client**: it makes plain HTTP requests against the Go MobilityAPI tier. If the server isn't running (or the `ships` collection isn't loaded), the next cells would fail with a connection error — so check it here first and print what to do.\n" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "execution_count": null, - "outputs": [], - "source": [ - "def _preflight():\n", - " try:\n", - " r = requests.get(f\"{HOST}/collections\", timeout=5); r.raise_for_status()\n", - " ids = [c[\"id\"] for c in r.json().get(\"collections\", [])]\n", - " except requests.exceptions.RequestException as e:\n", - " raise SystemExit(\n", - " f\"Cannot reach the MobilityAPI Go tier at {HOST} ({type(e).__name__}).\\n\"\n", - " \"This notebook is a client — start the server first:\\n\"\n", - " \" 1. PostgreSQL with the MobilityDB extension, holding the demo data\\n\"\n", - " \" (the `mfapi_demo` database with the `ships` collection loaded).\\n\"\n", - " \" 2. Build and run the tier: go build -o mfapi . && MFAPI_DSN= ./mfapi\\n\"\n", - " \" Override the URL with the MFAPI_HOST env var if it listens elsewhere.\\n\"\n", - " \" See tutorial/README.md and the repository README for details.\")\n", - " if COLLECTION_ID not in ids:\n", - " raise SystemExit(\n", - " f\"The tier at {HOST} is up but has no '{COLLECTION_ID}' collection (found: {ids}).\\n\"\n", - " \"Load the demo data into MobilityDB and register it in the `collections` table.\")\n", - " print(f\"MobilityAPI tier OK at {HOST} — collections: {ids}\")\n", - "\n", - "_preflight()\n" - ] - }, { "cell_type": "markdown", "id": "913a2af4", @@ -736,18 +689,14 @@ "source": [ "## Step 8 — Temporal properties\n", "\n", - "Temporal properties are user-supplied, time-varying non-spatial attributes of a feature\n", - "(fuel, cargo temperature, load…), stored as native MobilityDB temporal values.\n", - "`POST .../items/{mFeatureId}/tproperties` adds one or more (a single object or an array);\n", - "`GET .../tproperties` lists them and `GET .../tproperties/{name}` returns one as an OGC\n", - "`temporalProperty`. `?leaf=` selects the value(s) at given instants,\n", - "`POST .../tproperties/{name}` appends more values (temporally disjoint; overlap → `409`),\n", - "and `DELETE .../tproperties/{name}` removes it." + "`GET .../items/{mFeatureId}/tproperties` lists the derived temporal properties\n", + "(`velocity`, `distance`, `heading`); `GET .../tproperties/{name}` returns one as a\n", + "`temporalProperty`. `?leaf=` returns the value(s) at given instants." ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "31cee3e1", "metadata": { "execution": { @@ -757,33 +706,25 @@ "shell.execute_reply": "2026-05-28T22:04:54.141115Z" } }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "properties: ['velocity', 'distance', 'heading']\n", + "velocity at 2026-02-26T13:28:08+01: [22.780720020416847] m/s (interpolation Discrete)\n" + ] + } + ], "source": [ - "base = f\"{HOST}/collections/{COLLECTION_ID}/items/{fid}/tproperties\"\n", - "\n", - "# add a cargo-temperature property (TReal, degrees Celsius) sampled over the afternoon\n", - "prop = {\"name\": \"cargo_temp\", \"type\": \"TReal\", \"form\": \"Cel\",\n", - " \"description\": \"Cargo hold temperature\",\n", - " \"datetimes\": [\"2026-02-26T12:00:00+01\", \"2026-02-26T13:00:00+01\", \"2026-02-26T14:00:00+01\"],\n", - " \"values\": [4.0, 4.6, 5.1], \"interpolation\": \"Linear\"}\n", - "print(\"POST ->\", requests.post(base, json=prop).status_code)\n", - "\n", - "# it now appears in the feature's property list\n", - "print(\"list ->\", [p[\"name\"] for p in requests.get(base).json()[\"temporalProperties\"]])\n", - "\n", - "# read it back as an OGC temporalProperty\n", - "tp = requests.get(f\"{base}/cargo_temp\").json()\n", - "seq = tp[\"valueSequence\"][0]\n", - "print(f\"series -> {seq['values']} {tp['form']} (interpolation {seq['interpolation']})\")\n", - "\n", - "# leaf selector: the interpolated value at a single instant\n", - "leaf = requests.get(f\"{base}/cargo_temp\", params={\"leaf\": \"2026-02-26T13:30:00+01\"}).json()\n", - "print(\"leaf ->\", leaf[\"valueSequence\"][0][\"values\"])\n", + "r = requests.get(f\"{HOST}/collections/{COLLECTION_ID}/items/{fid}/tproperties\"); r.raise_for_status()\n", + "print(\"properties:\", [p[\"name\"] for p in r.json()[\"temporalProperties\"]])\n", "\n", - "# append more values (must be temporally disjoint), then remove the property\n", - "print(\"append ->\", requests.post(f\"{base}/cargo_temp\",\n", - " json={\"datetimes\": [\"2026-02-26T15:00:00+01\"], \"values\": [5.4], \"interpolation\": \"Linear\"}).status_code)\n", - "print(\"delete ->\", requests.delete(f\"{base}/cargo_temp\").status_code)" + "t0 = requests.get(f\"{HOST}/collections/{COLLECTION_ID}/items/{fid}/tgsequence\").json()[\"sequences\"][0][\"datetimes\"][0]\n", + "r = requests.get(f\"{HOST}/collections/{COLLECTION_ID}/items/{fid}/tproperties/velocity\",\n", + " params={\"leaf\": t0})\n", + "seq = r.json()[\"valueSequence\"][0]\n", + "print(f\"velocity at {t0}: {seq['values']} {r.json()['form']} (interpolation {seq['interpolation']})\")" ] }, {