Introduction

June 8, 2026 · View on GitHub

Test and Build Coverage Status MIT licensed DOI

Introduction

Map of the UK showing OS control points

A Rust library with FFI bindings for fast conversion between WGS84 longitude and latitude and British National Grid (epsg:27700) coordinates, using a Rust binary. Conversions use the Ordnance Survey OSTN15 transformation – a Transverse Mercator projection on the GRS80 ellipsoid followed by the OSTN15 grid-shift correction – for survey-quality accuracy.

Motivation

Python (etc.) is relatively slow; this type of conversion is usually carried out in bulk, so an order-of-magnitude improvement using FFI saves both time and energy.
The Convertbng Python wheel which uses this binary via ctypes and cython.

Accuracy

Conversions which solely use Helmert transforms are accurate to within around 5 metres, and are not suitable for calculations or conversions used in e.g. surveying. Thus, we use the OSTN15 transform, which adjusts for local variation within the Terrestrial Reference Frame by incorporating OSTN15 data. See here for more information.

A detailed treatment of the numeric precision and the measured accuracy of each implementation, suitable for citation in technical reports, is in doc/accuracy.md.

Tests

The library is well covered by tests. A full "pipeline" test which checks intermediate conversions is provided (though not run as part of the standard test suite):

test_osgb36_to_etrs89_iterations_detailed verifies the algorithm against the supplied OSTN15 test data, validating round-trip conversion: OSGB36 → ETRS89 → Lon/Lat → OSGB36.

Round-Trip Conversion Accuracy

By default the projection step uses the truncated series specified by Ordnance Survey (the "Redfearn" series). Its truncation error grows with distance from the 2°W central meridian, so the largest round-trip residuals occur at the extreme western isles. Of the 40 developer-pack points, only the two furthest west exceed 1 mm in the lon/lat → OSGB36 → lon/lat round trip:

Test PointLongitudeRound-trip error (default)Round-trip error (karney_tm)
TP31 (St Kilda)8.58°W4.97 mm0.61 mm
TP327.59°W1.31 mm0.29 mm

The remaining 38 points are sub-millimetre with either implementation.

Optional higher-accuracy projection (karney_tm)

Enabling the karney_tm Cargo feature replaces the truncated series with Karney's Krüger n-series Transverse Mercator, which is accurate to a few nanometres across the whole grid and keeps the round trip sub-millimetre everywhere, including the western isles:

lonlat_bng = { version = "x.x.x", features = ["karney_tm"] }

The default build reproduces the OS-specified (truncated) method, agreeing with Grid InQuest II to floating-point precision; the karney_tm build is more accurate but deliberately diverges from that convention by a few millimetres at the far west. See doc/accuracy.md for the full analysis, measured figures, and the performance trade-off.

Library Use

As a Rust Library

Add the following to your Cargo.toml (the latest version is displayed on the fourth badge at the top of this screen)

lonlat_bng = "x.x.x"

Note that lon, lat coordinates outside the UK bounding box will be transformed to (NAN, NAN), which cannot be mapped.

Error handling

The scalar conversion functions (e.g. convert_osgb36, convert_osgb36_to_ll) return Result<(f64, f64), TransformError>. TransformError names the cause and carries the offending coordinate(s):

  • OutOfBounds { axis, value, min, max } — an input is outside the valid range for the conversion;
  • OutsideOstn15Coverage { easting, northing } — the point has no OSTN15 grid coverage (e.g. offshore);
  • NonConvergent { easting, northing } — the iterative OSGB36 → ETRS89 step did not converge.

The bulk/threaded and FFI functions cannot return a Rust error across their boundary, so they continue to write (NAN, NAN) for any coordinate that fails to convert.

FFI

The FFI C-compatible functions exposed by the library are:
convert_to_bng_threaded(Array, Array) -> Array
convert_to_lonlat_threaded(Array, Array) -> Array

convert_to_osgb36_threaded(Array, Array) -> Array
convert_to_etrs89_threaded(Array, Array) -> Array)
convert_osgb36_to_ll_threaded(Array, Array) -> Array
convert_etrs89_to_ll_threaded(Array, Array) -> Array

convert_etrs89_to_osgb36_threaded(Array, Array) -> Array
convert_osgb36_to_etrs89_threaded(Array, Array) -> Array

convert_epsg3857_to_wgs84_threaded(Array, Array) -> Array

FFI and Memory Management

The library does not allocate memory using new vectors or arrays; the longitude and latitude arrays you pass to it via FFI are converted into mutable [slices, then mutated in-place before being passed back across the FFI boundary as C-compatible arrays. Thus, the calling code retains ownership of the allocated memory at all times – it is up to the calling program to ensure that the data passed to lonlat_bng live long enough, and are correctly freed (in practice, they will be freed automatically if using a dynamic language).

Building the Shared Library

Running cargo build --release will build an artefact called liblonlat_bng.dylib on macOS, and liblonlat_bng.a on *nix systems. Note that you'll have to generate liblonlat_bng.so for *nix hosts using the following steps:

  • ar -x target/release/liblonlat_bng.a
  • gcc -shared *.o -o target/release/liblonlat_bng.so -lrt

As a Python Package

convert_bng is available from PyPI for macOS, Windows, and *nix:
pip install convertbng
More information is available in its repository

Benchmark

A CProfile benchmark was run, comparing 50 runs of converting 1m random lon, lat pairs in NumPy arrays.

Methodology

  • 4 Amazon EC2 C4 (compute-optimised) systems were tested
  • The system was first calibrated by taking the mean of five calibration runs of 100,000 repeats
  • A benchmark program was then run for each of the three configurations. See the benches directory for details
  • The five slowest function calls for each benchmark were then displayed.

Results

EC2 Instance TypeProcessors (vCPU)Rust Ctypes (s)Rust Cython (s)Pyproj (s)Ctypes vs PyprojCython vs Pyproj
c4.xlarge414.78211.7149.37958.36%24.97%
c4.2xlarge88.6476.4219.256-6.57%-30.62%
c4.4xlarge166.4703.7169.398-31.49%-60.25%
c4.8xlarge364.9132.5019.308-48.05%-73.35%

Conclusion

Rust is faster than PROJ.4 on an 8-CPU system – even using ctypes – and outperforms it by greater margins as the number of CPUs increase: at 36 CPUs, Rust + Cython is over 3.7x faster.

Comparing Crossbeam and Rayon

Comparing how varying threads and weights affects overall speed, using cargo bench
On both 2- and 8-core i7 machines, running convert_bng_threaded_vec using one thread per core gives optimum performance, whereas Rayon does a good job at choosing its own optimum weight.

Comparison

License

The Blue Oak Model License 1.0.0

This software makes use of OSTN15 data, which is © Crown copyright, Ordnance Survey and the Ministry of Defence (MOD) 2016. All rights reserved. Provided under the BSD 2-clause license.