geo.distance
Great-circle distance in metres between two latitude/longitude points (haversine), to the millimetre.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 19 tests, run in TypeScript, Python and Rust.
What it does
The great-circle distance between two latitude/longitude points, in metres, by the haversine formula, rounded to the millimetre.
## The model
For example
distance(51.507, -0.128, 48.857, 2.352)→ 343,556.535 London to Parisdistance(40.641, -73.778, 51.47, -0.454)→ 5,540,018.97 JFK to Heathrowdistance(-33.869, 151.209, -37.814, 144.963)→ 713,428.466 Sydney to Melbourne, southern and eastern hemispheres
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn distance(from_lat: f64, from_lng: f64, to_lat: f64, to_lng: f64) -> f64
| from_lat | float | degrees, -90 to 90 |
| from_lng | float | degrees, -180 to 180 |
| to_lat | float | degrees, -90 to 90 |
| to_lng | float | degrees, -180 to 180 |
| returns | float | metres on a sphere of radius 6,371,008.8 m, rounded to 3 decimals |
Your code names it in one line, in the file that uses it
fune!(geo.distance@^1); // then call distance(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
// Why this does not call f64::sin, cos or atan2: those come from the platform
// maths library, which may differ in the last bit between Rust, JavaScript and
// Python, and a value next to a rounding boundary can then round differently.
// The trigonometry below uses only operations IEEE 754 requires to be
// correctly rounded (+ - * / sqrt floor), in the same order as the other two
// implementations, so the unrounded result is the same double everywhere.
// Rust never contracts a * b + c into a fused multiply-add on its own.
/// IUGG mean radius R1 = (2a + b) / 3 of the GRS 80 ellipsoid (Moritz,
/// "Geodetic Reference System 1980", Journal of Geodesy 74 (2000) 128-133).
const EARTH_RADIUS_METRES: f64 = 6371008.8;
const PI: f64 = 3.141592653589793;
const HALF_PI: f64 = PI / 2.0;
const DEGREES: f64 = PI / 180.0;
const S3: f64 = -1.0 / 6.0;
const S5: f64 = 1.0 / 120.0;
const S7: f64 = -1.0 / 5040.0;
const S9: f64 = 1.0 / 362880.0;
const S11: f64 = -1.0 / 39916800.0;
const S13: f64 = 1.0 / 6227020800.0;
const S15: f64 = -1.0 / 1307674368000.0;
const S17: f64 = 1.0 / 355687428096000.0;
const C2: f64 = -1.0 / 2.0;
const C4: f64 = 1.0 / 24.0;
const C6: f64 = -1.0 / 720.0;
const C8: f64 = 1.0 / 40320.0;
const C10: f64 = -1.0 / 3628800.0;
const C12: f64 = 1.0 / 479001600.0;
const C14: f64 = -1.0 / 87178291200.0;
const C16: f64 = 1.0 / 20922789888000.0;
const C18: f64 = -1.0 / 6402373705728000.0;
const A3: f64 = -1.0 / 3.0;
const A5: f64 = 1.0 / 5.0;
const A7: f64 = -1.0 / 7.0;
const A9: f64 = 1.0 / 9.0;
const A11: f64 = -1.0 / 11.0;
const A13: f64 = 1.0 / 13.0;
const A15: f64 = -1.0 / 15.0;
const A17: f64 = 1.0 / 17.0;
const A19: f64 = -1.0 / 19.0;
const A21: f64 = 1.0 / 21.0;
fn sin_small(r: f64) -> f64 {
let s = r * r;
let mut p = S17;
p = S15 + s * p;
p = S13 + s * p;
p = S11 + s * p;
p = S9 + s * p;
p = S7 + s * p;
p = S5 + s * p;
p = S3 + s * p;
r + r * s * p
}
fn cos_small(r: f64) -> f64 {
let s = r * r;
let mut p = C18;
p = C16 + s * p;
p = C14 + s * p;
p = C12 + s * p;
p = C10 + s * p;
p = C8 + s * p;
p = C6 + s * p;
p = C4 + s * p;
p = C2 + s * p;
1.0 + s * p
}
fn sin_cos(x: f64) -> (f64, f64) {
let k = (x / HALF_PI + 0.5).floor();
let r = x - k * HALF_PI;
let sr = sin_small(r);
let cr = cos_small(r);
match (k as i64).rem_euclid(4) {
0 => (sr, cr),
1 => (cr, -sr),
2 => (-sr, -cr),
_ => (-cr, sr),
}
}
fn atan_unit(u: f64) -> f64 {
let mut v = u;
v = v / (1.0 + (1.0 + v * v).sqrt());
v = v / (1.0 + (1.0 + v * v).sqrt());
v = v / (1.0 + (1.0 + v * v).sqrt());
let s = v * v;
let mut p = A21;
p = A19 + s * p;
p = A17 + s * p;
p = A15 + s * p;
p = A13 + s * p;
p = A11 + s * p;
p = A9 + s * p;
p = A7 + s * p;
p = A5 + s * p;
p = A3 + s * p;
8.0 * (v + v * s * p)
}
fn atan2_positive(y: f64, x: f64) -> f64 {
if x == 0.0 {
return if y == 0.0 { 0.0 } else { HALF_PI };
}
let t = y / x;
if t > 1.0 {
HALF_PI - atan_unit(x / y)
} else {
atan_unit(t)
}
}
/// Half away from zero on the binary64 value; + 0.0 turns -0.0 into 0.0.
fn round_to(x: f64, scale: f64) -> f64 {
let y = x.abs() * scale;
let mut r = y.floor();
if y - r >= 0.5 {
r += 1.0;
}
let out = r / scale;
(if x < 0.0 { -out } else { out }) + 0.0
}
fn check_latitude(value: f64) {
if !value.is_finite() {
panic!("latitude must be a finite number of degrees");
}
if value < -90.0 || value > 90.0 {
panic!("latitude must be between -90 and 90 degrees");
}
}
fn check_longitude(value: f64) {
if !value.is_finite() {
panic!("longitude must be a finite number of degrees");
}
if value < -180.0 || value > 180.0 {
panic!("longitude must be between -180 and 180 degrees");
}
}
/// Great-circle distance in metres (haversine, IUGG mean radius), to the millimetre.
///
/// # Panics
/// Panics if a coordinate is not finite or out of range.
pub fn distance(from_lat: f64, from_lng: f64, to_lat: f64, to_lng: f64) -> f64 {
check_latitude(from_lat);
check_longitude(from_lng);
check_latitude(to_lat);
check_longitude(to_lng);
let phi1 = from_lat * DEGREES;
let phi2 = to_lat * DEGREES;
// A longitude difference of 359 degrees is a 1 degree step across the
// antimeridian; sin^2 of the half-angle is the same either way.
let sin_half_lat = sin_cos(((to_lat - from_lat) * DEGREES) / 2.0).0;
let sin_half_lng = sin_cos(((to_lng - from_lng) * DEGREES) / 2.0).0;
let cos1 = sin_cos(phi1).1;
let cos2 = sin_cos(phi2).1;
let mut a = sin_half_lat * sin_half_lat + cos1 * cos2 * sin_half_lng * sin_half_lng;
if a < 0.0 {
a = 0.0;
}
if a > 1.0 {
a = 1.0;
}
// atan2 rather than asin(sqrt(a)): stays accurate for antipodal points too.
let c = 2.0 * atan2_positive(a.sqrt(), (1.0 - a).sqrt());
round_to(EARTH_RADIUS_METRES * c, 1000.0)
}
pub fn fune_vector(args: &[Value]) -> Value {
// Refuse what the typed signature cannot hold, with the wording TypeScript
// and Python use, rather than let the conversion below quietly change it.
if !matches!(args[0], Value::Int(_) | Value::Float(_)) {
panic!("latitude must be a finite number of degrees");
}
if !matches!(args[1], Value::Int(_) | Value::Float(_)) {
panic!("longitude must be a finite number of degrees");
}
if !matches!(args[2], Value::Int(_) | Value::Float(_)) {
panic!("latitude must be a finite number of degrees");
}
if !matches!(args[3], Value::Int(_) | Value::Float(_)) {
panic!("longitude must be a finite number of degrees");
}
Value::Float(distance(
args[0].as_f64(),
args[1].as_f64(),
args[2].as_f64(),
args[3].as_f64(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add geo.distance
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./geo.distance-1.0.0-rust.fune, or fetch it from a terminal with fune pull geo.distance@1.0.0:rust.
The whole function, every language, is one file too: geo.distance-1.0.0.fune, 23,430 bytes, sha256 701b702975d223bf7fdc1a539827f06ebd9962b8f8d84d0c1216bca38fc31935. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before geo.distance
after — your function gets the result and the arguments, and returns the final result.
// fune: after geo.distance
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show geo.distance --steps.
// fune: step geo.distance after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| London to Paris | 51.507, -0.128, 48.857, 2.352 | → | 343,556.535 |
| JFK to Heathrow | 40.641, -73.778, 51.47, -0.454 | → | 5,540,018.97 |
| Sydney to Melbourne, southern and eastern hemispheres | -33.869, 151.209, -37.814, 144.963 | → | 713,428.466 |
| Nashville to Los Angeles, the classic haversine example, on the IUGG mean radius | 36.12, -86.67, 33.94, -118.4 | → | 2,886,448.43 |
| one degree of longitude on the equator is pi R / 180 | 0, 0, 0, 1 | → | 111,195.08 |
| one degree of latitude is the same length on a sphere | 0, 0, 1, 0 | → | 111,195.08 |
| across the antimeridian is one degree, not 359 (naive longitude difference gets this wrong) | 0, 179.5, 0, -179.5 | → | 111,195.08 |
| half way round the equator is half the circumference | 0, 0, 0, 180 | → | 20,015,114.442 |
| pole to pole, antipodal | 90, 0, -90, 0 | → | 20,015,114.442 |
| over the north pole: 89.9N 0E to 89.9N 180E is 0.2 degrees | 89.9, 0, 89.9, 180 | → | 22,239.016 |
Show the other 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the same pole at two longitudes is the same point | -90, 0, -90, 123 | → | 0 |
| identical points are zero | 51.507, -0.128, 51.507, -0.128 | → | 0 |
| about a metre apart keeps millimetre accuracy (the acos formula loses it) | 51.5, -0.1, 51.5, -0.1 | → | 1.001 |
| about 11 centimetres | 10, 20, 10, 20 | → | 0.111 |
| the order of the points does not matter | 48.857, 2.352, 51.507, -0.128 | → | 343,556.535 |
| latitude above 90 is an error | 91, 0, 0, 0 | → | error: latitude must be between -90 and 90 degrees |
| latitude below -90 is an error | 0, 0, -90.5, 0 | → | error: latitude must be between -90 and 90 degrees |
| longitude above 180 is an error, not silently wrapped | 0, 181, 0, 0 | → | error: longitude must be between -180 and 180 degrees |
| a coordinate that is not a number is an error | 51.5, 0, 0, 0 | → | error: latitude must be a finite number of degrees |
More from the author
The Earth is taken as a sphere of radius 6,371,008.8 m, the IUGG mean radius R1 = (2a + b) / 3 of the GRS 80 ellipsoid (H. Moritz, "Geodetic Reference System 1980", Journal of Geodesy 74 (2000) 128-133; also Bulletin Géodésique 54 (1980)). Many snippets use 6,371,000 m or the equatorial 6,378,137 m; those give different answers, which is why the radius is part of the contract.
A sphere is not the Earth. Against the WGS 84 ellipsoid (Vincenty, Karney) the haversine distance is off by up to about 0.5%. Use this for "how far is the nearest depot", delivery radii and sorting by distance, not for surveying. The millimetre rounding is about reproducibility, not accuracy.
Longitudes do not need wrapping: 179.5 to -179.5 is 1 degree across the antimeridian, because the formula only uses sin² of half the difference. Coordinates outside -90..90 and -180..180 are errors rather than being wrapped, since they usually mean latitude and longitude were swapped.
The result uses atan2(√a, √(1−a)) rather than asin(√a). For points closer than a metre or so it keeps millimetre accuracy, where the spherical law of cosines (acos) loses it. Near-antipodal points are ill-conditioned for any haversine in double precision: within about a degree of the antipode the answer can be a few millimetres from the exact spherical value (elsewhere it is within 1e-7 m).
## Why the answers are identical in every language
JavaScript's Math.sin, Python's math.sin and Rust's f64::sin come from different maths libraries, which are allowed to differ in the last bit. Rounding to millimetres hides that almost always, but not always: a value that lands on a rounding boundary can go either way.
So this capability does not call them. It carries its own sin, cos and atan, built only from operations IEEE 754 requires to be correctly rounded (+ − × ÷, sqrt, floor), evaluated in the same order in all three languages: range reduction to a quarter turn, fixed-length Taylor polynomials in Horner form, and angle halving for atan. No language fuses a multiply and an add behind your back (JavaScript and Python cannot; Rust does not without an explicit mul_add). The unrounded distance is therefore the same double in TypeScript, Python and Rust, and the rounding step is only presentation. This was checked on 4,000 random pairs (including near-coincident and near-antipodal ones): the unrounded and rounded results were bit-for-bit identical in all three.
## Rounding
Half away from zero, applied to the binary64 value: y = d × 1000, r = floor(y), r + 1 if y − r ≥ 0.5, result r / 1000. Not Python's round(), which rounds halves to even.
Files
| Path | Bytes |
|---|---|
| README.md | 2,805 |
| impl/python.py | 4,692 |
| impl/rust.rs | 5,974 |
| impl/typescript.ts | 5,189 |
| vectors.json | 2,378 |