latlon #
WGS 84 positions and what you do with them: great-circle distance, indexes over points, segments and areas, and operations on bounding boxes.
Overview #
The library has four parts, kept together because callers use them together.
Latlon.t is a position in decimal degrees on WGS 84. The distance between
two positions is the haversine distance in kilometres on the IUGG mean
sphere.
Latlon.Index stores positions, each with a payload, and answers distance
queries over them: nearest, k_nearest, within_km and inverse-distance
interpolation (idw). Latlon.Segments finds the nearest of a set of line
segments. Both give exact answers, including at the poles and across the antimeridian, and
neither needs a cell size from the caller.
Latlon.Ring is a closed ring of positions, and Latlon.Rings indexes the
box around each ring to find which area contains a point. The box lookup
narrows the candidates and Ring.contains makes the decision. Its boundary
rule means two neighbouring areas can never both claim a point on their
shared edge.
Latlon.Box handles axis-aligned boxes. You can cut a box into tiles (for a
server that will not return a whole country in one request), take the box
around a disc, subtract one rectangle from another, and measure the distance
from a box to a point. Subtraction gives you the part of a region that none
of a set of covered areas reached.
It needs OCaml 4.14 or later, and nox-rtree for the index.
Install #
$ opam install latlon
Usage #
# let paris = Latlon.v ~lat:48.8566 ~lon:2.3522
val paris : Latlon.t = <abstr>
# let london = Latlon.v ~lat:51.5074 ~lon:(-0.1278)
val london : Latlon.t = <abstr>
# Latlon.distance_km paris london
- : float = 343.556534880883248
A position is abstract because its longitude is normalised, which gives each
place a single representation. distance_km is periodic in longitude, so 225°
and −135° are the same meridian to it. An index compares raw coordinates, and
without normalisation a point stored as 225° would never match a query at
−135°.
# Latlon.lon (Latlon.v ~lat:0. ~lon:225.)
- : float = -135.
# Latlon.equal (Latlon.v ~lat:0. ~lon:180.) (Latlon.v ~lat:0. ~lon:(-180.))
- : bool = true
An index carries a payload of your own type:
# let cities =
[ paris, "Paris"; london, "London";
Latlon.v ~lat:45.7640 ~lon:4.8357, "Lyon" ]
val cities : (Latlon.t * string) list =
[(<abstr>, "Paris"); (<abstr>, "London"); (<abstr>, "Lyon")]
# let index = Latlon.Index.v cities
val index : string Latlon.Index.t = <abstr>
# Latlon.Index.nearest index (Latlon.v ~lat:47. ~lon:3.) |> Option.map snd
- : string option = Some "Lyon"
# Latlon.Index.k_nearest index (Latlon.v ~lat:47. ~lon:3.) 2 |> List.map snd
- : string list = ["Lyon"; "Paris"]
A box can be split into tiles, and one box subtracted from another:
# let window = Latlon.Box.v ~south:47. ~west:(-2.) ~north:51. ~east:2.
val window : Latlon.Box.t =
{Latlon.Box.south = 47.; west = -2.; north = 51.; east = 2.}
# List.length (Latlon.Box.split ~degrees:2. window)
- : int = 4
# Latlon.Box.subtract window (Latlon.Box.v ~south:49. ~west:(-2.) ~north:51. ~east:2.)
- : Latlon.Box.t list =
[{Latlon.Box.south = 47.; west = -2.; north = 49.; east = 2.}]
Rings.containing lists the areas that hold a point. The index first keeps
the rings whose box contains the point, then Ring.contains checks each of
them exactly, so a point inside a ring's box but outside the ring belongs to
no area:
# let ring corners =
Latlon.Ring.v (List.map (fun (lat, lon) -> Latlon.v ~lat ~lon) corners)
val ring : (float * float) list -> Latlon.Ring.t = <fun>
# let areas =
Latlon.Rings.v
[ ring [ 48., 2.; 49., 2.; 49., 3.; 48., 3. ], "north";
ring [ 47., 2.; 48., 2.; 48., 3.; 47., 3. ], "south" ]
val areas : string Latlon.Rings.t = <abstr>
# Latlon.Rings.containing areas (Latlon.v ~lat:48.5 ~lon:2.5)
- : string list = ["north"]
# Latlon.Rings.containing areas (Latlon.v ~lat:46. ~lon:2.5)
- : string list = []
The two areas share the parallel at 48°, and a point on it belongs to exactly one of them. A ring owns the southern and western parts of its boundary and leaves the northern and eastern parts to its neighbours, so adjacent areas split their shared edge between them.
# Latlon.Rings.containing areas (Latlon.v ~lat:48. ~lon:2.5)
- : string list = ["north"]
Box.distance_km uses the same haversine as Latlon.distance_km, so
comparing it with a radius gives the same result as scanning the points one by
one:
# Latlon.Box.distance_km window (Latlon.v ~lat:48. ~lon:(-1.))
- : float = 0.
# Latlon.Box.distance_km window (Latlon.v ~lat:48. ~lon:3.) > 50.
- : bool = true
Coordinates #
Positions are decimal degrees on WGS 84: latitude in [-90, 90], longitude in
[-180, 180], in that order everywhere in this library. GeoJSON
(RFC 7946 Section 3.1.1) writes them
the other way round, longitude first, and its bbox member is
[west, south, east, north], so a Latlon.Box.t needs reordering on the way
in and out.
Distances are great-circle arcs on a sphere of the IUGG mean radius (6371.0088 km). That is a spherical approximation of the WGS 84 ellipsoid, off by up to about 0.5%; use an ellipsoidal geodesic if that matters.
A position normalises its longitude, but a box is kept as given. The plain
box operations do rectangle arithmetic on degrees, so a box with
west > east is treated as empty and never wraps across the antimeridian.
The distance queries do wrap. Box.around, Index and Segments handle the
antimeridian and the poles exactly, because a nearest neighbour search that
stopped at ±180° would return wrong answers near it.
Exact nearest-neighbour queries #
An R-tree (nox-rtree) answers box queries. A
distance query guesses a radius, asks the R-tree for everything in the boxes
that cover the disc of that radius, and doubles the radius until the answer is
complete. Since Box.around covers the whole disc, any point outside those
boxes is further away than the radius. Once the k-th neighbour found lies
within the radius, no nearer point can remain.
This only works if Box.around really contains the disc, so it uses the exact spherical bound asin (sin σ / cos φ), where σ is the disc's
angular radius (its radius in kilometres over the Earth's mean radius R =
6371.0088 km) and φ the latitude of its centre. That is the half-width in
longitude of the smallest box around the spherical cap, reached where a meridian
is tangent to the cap; once the cap reaches a pole (sin σ ≥ cos φ) the box
becomes a full band of longitude. A local degrees-per-kilometre scale, σ / cos
φ, falls short of it by about R σ³ tan² φ / 6 on the ground: 12 m for a 100 km
disc at 60° latitude, 1.6 km for a 500 km one. The bound is checked against S2's
Cap.get_rect_bound in test/interop/python.
Related work #
geocaml/geoprovides geospatial primitives over Owl, and is the reason this package is not calledgeo. It is planar: its distance is Euclidean and its only haversine is the initial bearing. It has no index, and its rectangles have no subtraction.rtreeis the R-tree this indexes on, forked asnox-rtree. It answers window queries; the nearest-neighbour layer on top is here.geojsonis an RFC 7946 codec, which reads the coordinates this library measures.ocaml-projprovides PROJ bindings, for moving between coordinate reference systems. This library stays in WGS 84 degrees and never reprojects.h3provides bindings to Uber's hexagonal index, which does handle the whole sphere uniformly, at the cost of a C dependency.
License #
ISC. See LICENSE.md.