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:
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.
Step 4: Base-29 Encoding¶
The Hilbert distance d is encoded in base-29 using an alphabet designed to avoid ambiguity:
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:
- Split
CI-PV9XD→ domainCI, codePV9XD - Base-29 → Hilbert distance
d - Hilbert distance → grid position
(x, y) - 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:
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.