SpatioTemporal Asset Catalog items, in pure OCaml
README.md

stac #

SpatioTemporal Asset Catalog (STAC) 1.1.0 items in OCaml.

Earth-observation archives publish their images as STAC items. Search tools, tilers and notebooks can then find and read an image without knowing anything about the archive it sits in. (A tiler is a server that reads the pixels a STAC asset points at and cuts the large georeferenced image into the small fixed-size tiles a web map asks for at each zoom level.) A STAC item is a GeoJSON Feature (RFC 7946). Its properties hold the acquisition metadata and its assets point at the pixels. This library builds items and serialises them. It writes the footprint with the nox-json.geojson codec, so the geometry comes out of an RFC 7946 implementation.

  • Stac.Item is the item itself: an identifier, a footprint with its bounding box, the acquisition time, its links and its assets.
  • Stac.Asset says where some bytes live and what they contain.
  • Stac.Band describes one band of the values in an asset.
  • Stac.Data_type is the sample width, named as the specification names it.
  • Stac.Link is one link out of an item.

Each constructor returns an error for anything the published JSON schema forbids. That covers an empty identifier, a bounding box of any length but four or six, a collection without a matching link, an empty band, and a property that repeats the datetime. The schemas are committed under test/schemas. The tests take the member names, the required lists and the data_type vocabulary from those files, and REGEN=1 dune build @traces fetches them again.

Installation #

Install with opam:

$ opam install stac

If opam cannot find the package, it may not yet be released in the public opam-repository. Add the overlay repository, then install it:

$ opam repo add samoht https://tangled.org/gazagnaire.org/opam-overlay.git
$ opam update
$ opam install stac

Usage #

This is how the catalogue describes one capture:

# let point =
    Geojson.point
      (Geojson.Geojson_object.v [| -121.7222; 39.9962 |] None
         (Json.Value.object' []))
val point : [> `Point of Geojson.Position.t Geojson.object' ] =
  `Point <abstr>
# let at s = match Ptime.of_rfc3339 s with Ok (t, _, _) -> t | Error _ -> assert false
val at : string -> Ptime.t = <fun>
# let pixels =
    Result.get_ok
      (Stac.Asset.v ~href:"/captures/cap-0000/pixels"
         ~media_type:"application/octet-stream" ~roles:[ "data" ]
         ~bands:
           [ Result.get_ok
               (Stac.Band.v ~name:"nir" ~data_type:Stac.Data_type.Uint16 ()) ]
         ())
val pixels : Stac.Asset.t = <abstr>
# let item =
    Result.get_ok
      (Stac.Item.v ~id:"cap-0000"
         ~geometry:(point, [| -121.7222; 39.9962; -121.7222; 39.9962 |])
         ~datetime:(Stac.Datetime.At (at "2026-07-16T18:50:53Z"))
         ~links:
           [ Result.get_ok
               (Stac.Link.v ~rel:"self" ~href:"/captures/cap-0000"
                  ~media_type:"application/geo+json" ()) ]
         ~assets:[ ("image", pixels) ] ())
val item : Stac.Item.t = <abstr>
# print_endline (Stac.Item.to_string item)
{"type":"Feature","stac_version":"1.1.0","id":"cap-0000","geometry":{"type":"Point","coordinates":[-121.7222,39.9962]},"bbox":[-121.7222,39.9962,-121.7222,39.9962],"properties":{"datetime":"2026-07-16T18:50:53Z"},"links":[{"rel":"self","href":"/captures/cap-0000","type":"application/geo+json"}],"assets":{"image":{"href":"/captures/cap-0000/pixels","type":"application/octet-stream","roles":["data"],"bands":[{"name":"nir","data_type":"uint16"}]}}}
- : unit = ()

A band is one spectral channel of an image; this one is near-infrared (nir). It gives only the sample width. With no raster:scale, a band holds raw digital numbers, the integers the sensor recorded for each pixel. Those numbers turn into a physical quantity such as reflectance only once a scale and an offset are applied. Leaving the scale out tells a reader not to take them for one.

The schema allows two shapes of footprint and the library writes both. An item without a geometry has null in its place and no bounding box:

# print_endline
    (Stac.Item.to_string
       (Result.get_ok
          (Stac.Item.v ~id:"cap-0001"
             ~datetime:(Stac.Datetime.At (at "2026-07-16T18:50:53Z"))
             ~links:[] ~assets:[] ())))
{"type":"Feature","stac_version":"1.1.0","id":"cap-0001","geometry":null,"properties":{"datetime":"2026-07-16T18:50:53Z"},"links":[],"assets":{}}
- : unit = ()

Specification #

License #

ISC. See LICENSE.md.