Skip to content

How It Works

The Problem

GPS coordinates like (5.34519, -4.02868) are precise but unwieldy. They're hard for humans to remember, communicate, or write on paper. In regions without postal addresses, there's no alternative — until now.

The Solution: Hilbert Curve Bijection

Yoro uses a 2D Hilbert space-filling curve to create a reversible mapping between GPS coordinates and short alphanumeric codes.

Step 1: Domain Bounding Box

Each domain (e.g. CI for Cote d'Ivoire) defines a geographic bounding box:

CI: lat [4.36, 10.74], lon [-8.6, -2.49]

This constrains the coordinate space, giving better resolution per character than a global system.

Step 2: Grid Discretization

The bounding box is divided into a 2^p × 2^p grid, where p is the Hilbert order (precision). For p=12, that's a 4096×4096 grid = 16.7 million cells over Cote d'Ivoire.

The GPS coordinate (lat, lon) is mapped to a grid cell (x, y):

x = floor((lon - lon_min) / (lon_max - lon_min) × 2^p)
y = floor((lat - lat_min) / (lat_max - lat_min) × 2^p)

Step 3: Hilbert Curve Index

The 2D grid position (x, y) is converted to a 1D Hilbert distance d using the standard Hilbert curve algorithm. This is the key insight: the Hilbert curve preserves spatial locality — nearby points in 2D map to nearby values in 1D.

(x, y) → d    (via xy2d)
d → (x, y)    (via d2xy, the inverse)

Step 4: Base-29 Encoding

The Hilbert distance d is encoded in base-29 using an alphabet designed to avoid ambiguity:

2 3 4 5 6 7 8 9 A B C D E F G H J K M N P Q R S T V W X Y

Excluded characters: 0 (confused with O), 1 (confused with I/L), I, L, O, U (confused with V).

Step 5: Domain Prefix

The final code is {domain}-{base29}, e.g. CI-PV9XD.

Decoding

Decoding reverses the process exactly:

  1. Split CI-PV9XD → domain CI, code PV9XD
  2. Base-29 → Hilbert distance d
  3. Hilbert distance → grid position (x, y)
  4. Grid position → GPS center of cell (lat, lon) + bounding box

Why Hilbert Curves?

Why not just use a simple row-major grid? The Hilbert curve has a unique property: it preserves spatial locality better than any other space-filling curve. Two codes that are numerically close are also geographically close. This means:

  • Prefix sharing — codes in the same area tend to share leading characters
  • Neighbor queries — the 8 adjacent cells can be computed with simple arithmetic
  • Range queries — a bounding box maps to a small set of Hilbert index ranges

The Base-29 Quantization Effect

The code length k is determined by:

k = ceil(2p × log(2) / log(29))

Since k must be an integer, several values of p produce the same k. Only the canonical precision — the highest p for each k — fully exploits the address space. Use yoro.precision_levels() to see them, and yoro.snap_precision(p) to check which canonical p your requested value maps to.

Not every typeable code is a real code

The address space of k base-29 characters is larger than the grid it addresses: 29^k against 4^p. On a 6-character body that is 594,823,321 codes for 268,435,456 cells — roughly 55% of what a person could type addresses no cell at all.

Before 0.4.0 those codes decoded anyway, wrapping around onto a cell that was already someone else's. decode("CI-222227") and decode("CI-F4HE38") returned the same point. For a system whose entire job is naming a place unambiguously, that is the worst possible failure: a mistyped code did not raise, it named a different address, plausibly, inside the country.

Since 0.4.0 the index is checked against the grid and an out-of-range code raises ValueError. Codes produced by encode() were always in range, so nothing that was ever issued is affected — only codes that were never valid to begin with.

Catching typos before they reach a database

The range check rejects the ~55% of bodies that address nothing. It cannot catch a typo that lands on another valid cell — and most typos do.

For codes that get dictated over the phone, written on a receipt or read off a shop front, add a check character:

code = yoro.encode(12.63, -8.00, domain="ML", check=True)  # one character longer
yoro.decode(code, check=True)  # raises if mistyped

The character is (Σ wᵢ · vᵢ) mod 29 with distinct non-zero weights. Because 29 is prime, Z/29Z is a field, and that buys a guarantee rather than a probability: every single-character substitution and every transposition of two adjacent characters changes the sum, so both are always caught. The alphabet already excludes the confusable glyphs (O/0, I/1/L, U, Z), so what remains is genuine mistyping.

It is opt-in because it costs a character, and a code stored machine-to-machine has nothing to gain from it. Note that a checked code and an unchecked one are not interchangeable — decode with the same flag you encoded with, and ask neighbors(code, check=True) for neighbours that carry their own.