Skip to main content

yo_common/
geo.rs

1//! The geospatial kernels, which are Redis's geohash arithmetic.
2//!
3//! This sits here rather than beside the geo commands because two very
4//! different callers need the same arithmetic. `GEOSEARCH` walks a sorted set
5//! with it and a `GEOFILTER` on a search index walks a numeric index with it,
6//! and the two only agree with each other about where a boundary falls if they
7//! share a single copy of the interleave, the haversine and the box cover.
8//!
9//! A geo key in Redis is not a type. It is a sorted set whose scores happen to
10//! be 52 bit interleaved geohashes, and every geo command is a sorted set
11//! command with some arithmetic in front of it. `ZSCORE` on a geo key answers
12//! the raw hash, `ZRANGE` works, `TYPE` says `zset`, and `OBJECT ENCODING` says
13//! `listpack` or `skiplist`. That is not an accident of the implementation, it
14//! is the documented behaviour, so it is what we do too.
15//!
16//! # The score
17//!
18//! Longitude is mapped from [-180, 180] and latitude from [-85.05112878,
19//! 85.05112878], which are the EPSG:900913 limits and the reason you cannot
20//! store a point at either pole. Each is scaled to 26 bits and the two are
21//! interleaved, latitude in the even positions and longitude in the odd ones,
22//! giving 52 bits, which is exactly what an `f64` holds without losing anything.
23//! That last part is why the score can be a float at all.
24//!
25//! A shorter hash is the same thing with fewer bits, and it names a box rather
26//! than a point. [`struct@Hash`] carries the step so the two cannot be confused, and
27//! [`align`] is what turns a box into the score range that covers it.
28//!
29//! # The search
30//!
31//! A radius search is nine range queries. Work out how many bits of hash make a
32//! box about the size of the search area, find the box the centre is in, take
33//! its eight neighbours, and ask the sorted set for every member whose score
34//! falls in one of those nine ranges. Then throw away the ones that are in a box
35//! but outside the actual circle. The boxes are a filter and the distance is the
36//! answer.
37//!
38//! Two adjustments in [`areas`] are what make that correct rather than nearly
39//! correct, and both are Redis's. The step estimate can be one too coarse near
40//! the edge of a box, so the four side neighbours are decoded and the step is
41//! dropped by one if any of them fails to reach past the bounding box. And a
42//! neighbour that is entirely outside the bounding box is zeroed rather than
43//! searched, which is three of the nine gone in the common case.
44//!
45//! # The distance
46//!
47//! Haversine on a sphere of radius 6372797.560856 metres, which is the WGS-84
48//! quadratic mean radius. Not Vincenty, not the ellipsoid, and not accurate to
49//! better than about half a percent at continental distances. It is the number
50//! Redis answers and a client comparing our `GEODIST` against its own is
51//! comparing against this, so a better formula would read as a bug.
52//!
53//! The one shortcut in it is Redis's too: when the two longitudes are exactly
54//! equal the haversine collapses to `asin(sin(x))`, which is `x` over the
55//! latitude range, so the arc is computed directly and the trigonometry is
56//! skipped.
57
58use core::f64::consts::PI;
59
60/// How many bits of hash a full precision point uses on each axis.
61pub const STEP_MAX: u8 = 26;
62/// The lowest longitude that can be stored.
63pub const LON_MIN: f64 = -180.0;
64/// The highest longitude that can be stored.
65pub const LON_MAX: f64 = 180.0;
66/// The lowest latitude that can be stored.
67///
68/// Not -90. The Mercator projection the hash is built on does not reach the
69/// poles, and this is where it is cut off.
70pub const LAT_MIN: f64 = -85.051_128_78;
71/// The highest latitude that can be stored.
72pub const LAT_MAX: f64 = 85.051_128_78;
73
74/// The radius of the earth the distances are computed on, in metres.
75///
76/// The WGS-84 quadratic mean radius. Redis's constant, to every digit it writes.
77const EARTH_RADIUS: f64 = 6_372_797.560_856;
78/// Half the circumference of the Mercator projection, in metres.
79const MERCATOR_MAX: f64 = 20_037_726.37;
80/// Radians in a degree.
81const DEG_TO_RAD: f64 = PI / 180.0;
82
83/// The alphabet a `GEOHASH` string is written in.
84///
85/// Base 32 with `a`, `i`, `l` and `o` left out, which is the standard geohash
86/// alphabet and not one of the base 32 alphabets anything else uses.
87const ALPHABET: &[u8; 32] = b"0123456789bcdefghjkmnpqrstuvwxyz";
88
89/// How many characters a `GEOHASH` reply has.
90pub const HASH_CHARS: usize = 11;
91
92/// What a distance is measured in.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Unit {
95    /// Metres, and the default when a command leaves the unit off.
96    M,
97    /// Kilometres.
98    Km,
99    /// Feet.
100    Ft,
101    /// Miles.
102    Mi,
103}
104
105impl Unit {
106    /// How many metres one of these is.
107    ///
108    /// A mile is 1609.34 rather than 1609.344, which is wrong by about two
109    /// millimetres and is the number Redis divides by, so it is the number a
110    /// client's own conversion has been checked against.
111    #[must_use]
112    pub const fn metres(self) -> f64 {
113        match self {
114            Unit::M => 1.0,
115            Unit::Km => 1000.0,
116            Unit::Ft => 0.3048,
117            Unit::Mi => 1609.34,
118        }
119    }
120
121    /// Read a unit the way a command spells it, in any case.
122    #[must_use]
123    pub fn parse(word: &[u8]) -> Option<Unit> {
124        let mut buf = [0u8; 2];
125        if word.len() > 2 {
126            return None;
127        }
128        for (i, b) in word.iter().enumerate() {
129            buf[i] = b.to_ascii_lowercase();
130        }
131        match &buf[..word.len()] {
132            b"m" => Some(Unit::M),
133            b"km" => Some(Unit::Km),
134            b"ft" => Some(Unit::Ft),
135            b"mi" => Some(Unit::Mi),
136            _ => None,
137        }
138    }
139}
140
141/// A box of the world, named by however many bits of hash it took to name it.
142///
143/// At [`STEP_MAX`] this is a point as far as anything can tell. Below that it is
144/// an area, and [`align`] turns it into the range of scores inside it.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub struct Hash {
147    /// The interleaved bits, `step * 2` of them, low aligned.
148    pub bits: u64,
149    /// How many bits of each axis are in there.
150    pub step: u8,
151}
152
153/// The corners of a box, in degrees.
154#[derive(Debug, Clone, Copy, PartialEq)]
155pub struct Area {
156    /// The lowest and highest longitude the box covers.
157    pub lon: (f64, f64),
158    /// The lowest and highest latitude the box covers.
159    pub lat: (f64, f64),
160}
161
162impl Area {
163    /// The middle of the box, which is what a stored point decodes to.
164    #[must_use]
165    pub fn centre(&self) -> (f64, f64) {
166        let lon = ((self.lon.0 + self.lon.1) / 2.0).clamp(LON_MIN, LON_MAX);
167        let lat = ((self.lat.0 + self.lat.1) / 2.0).clamp(LAT_MIN, LAT_MAX);
168        (lon, lat)
169    }
170}
171
172/// What is being searched for, and around where.
173#[derive(Debug, Clone, Copy)]
174pub struct Shape {
175    /// The centre longitude.
176    pub lon: f64,
177    /// The centre latitude.
178    pub lat: f64,
179    /// A circle or a rectangle, with its size in `unit`.
180    pub kind: Kind,
181    /// What `kind`'s numbers are measured in.
182    pub unit: Unit,
183}
184
185/// A circle or a rectangle.
186#[derive(Debug, Clone, Copy)]
187pub enum Kind {
188    /// `BYRADIUS`, and every `GEORADIUS` form.
189    Circle {
190        /// The radius, in the shape's unit.
191        radius: f64,
192    },
193    /// `BYBOX`, which is an axis aligned rectangle and not a box on the hash
194    /// grid. The two have nothing to do with each other.
195    Rect {
196        /// The full width, in the shape's unit.
197        width: f64,
198        /// The full height, in the shape's unit.
199        height: f64,
200    },
201}
202
203impl Shape {
204    /// The radius, or the half diagonal of the rectangle, in metres.
205    ///
206    /// The half diagonal and not the half width, because the step estimate has
207    /// to cover the corners of the rectangle and not just its sides.
208    #[must_use]
209    pub fn reach(&self) -> f64 {
210        let m = self.unit.metres();
211        match self.kind {
212            Kind::Circle { radius } => radius * m,
213            Kind::Rect { width, height } => {
214                let (w, h) = (width / 2.0 * m, height / 2.0 * m);
215                (w * w + h * h).sqrt()
216            }
217        }
218    }
219
220    /// The half width and half height of the bounding box, in metres.
221    #[must_use]
222    fn half(&self) -> (f64, f64) {
223        let m = self.unit.metres();
224        match self.kind {
225            Kind::Circle { radius } => (radius * m, radius * m),
226            Kind::Rect { width, height } => (width / 2.0 * m, height / 2.0 * m),
227        }
228    }
229
230    /// The smallest longitude and latitude rectangle that contains the shape.
231    ///
232    /// The left and right edges of a shape on the sphere are curved, so the
233    /// widest part of it is the edge nearer the equator. That is why the two
234    /// hemispheres take their width from different edges rather than both from
235    /// the centre.
236    #[must_use]
237    pub fn bounds(&self) -> Area {
238        let (width, height) = self.half();
239        let lat_delta = (height / EARTH_RADIUS) / DEG_TO_RAD;
240        let top = (width / EARTH_RADIUS / ((self.lat + lat_delta) * DEG_TO_RAD).cos()) / DEG_TO_RAD;
241        let bottom =
242            (width / EARTH_RADIUS / ((self.lat - lat_delta) * DEG_TO_RAD).cos()) / DEG_TO_RAD;
243        let lon_delta = if self.lat < 0.0 { bottom } else { top };
244        Area {
245            lon: (self.lon - lon_delta, self.lon + lon_delta),
246            lat: (self.lat - lat_delta, self.lat + lat_delta),
247        }
248    }
249
250    /// Whether a point is inside the shape, and how far away it is in metres.
251    ///
252    /// Nothing at all if it is outside, so the caller never has to compare a
253    /// distance against a radius itself and get the boundary case wrong. The
254    /// boundary is inclusive on both shapes.
255    #[must_use]
256    pub fn covers(&self, lon: f64, lat: f64) -> Option<f64> {
257        match self.kind {
258            Kind::Circle { radius } => {
259                let d = distance(self.lon, self.lat, lon, lat);
260                (d <= radius * self.unit.metres()).then_some(d)
261            }
262            Kind::Rect { width, height } => {
263                let m = self.unit.metres();
264                // Latitude first, because it is the cheap one: a difference of
265                // latitude is an arc and needs no trigonometry at all.
266                if lat_distance(lat, self.lat) > height * m / 2.0 {
267                    return None;
268                }
269                if distance(lon, lat, self.lon, lat) > width * m / 2.0 {
270                    return None;
271                }
272                Some(distance(self.lon, self.lat, lon, lat))
273            }
274        }
275    }
276}
277
278/// The nine boxes a search has to look in, and where the search area is.
279#[derive(Debug, Clone, Copy)]
280pub struct Search {
281    /// The nine boxes, centre first. A box with no bits in it was ruled out and
282    /// is not to be searched.
283    pub boxes: [Hash; 9],
284    /// The rectangle the boxes are covering, which is what a candidate is
285    /// finally tested against.
286    pub bounds: Area,
287}
288
289/// Spread the low 32 bits of each argument into alternating positions.
290///
291/// `x` lands in the even bits and `y` in the odd ones. Redis calls with latitude
292/// first, so latitude is the even half of every score in the wild.
293#[must_use]
294fn interleave(x: u32, y: u32) -> u64 {
295    const B: [u64; 5] = [
296        0x5555_5555_5555_5555,
297        0x3333_3333_3333_3333,
298        0x0f0f_0f0f_0f0f_0f0f,
299        0x00ff_00ff_00ff_00ff,
300        0x0000_ffff_0000_ffff,
301    ];
302    let mut x = u64::from(x);
303    let mut y = u64::from(y);
304    for (shift, mask) in [(16, B[4]), (8, B[3]), (4, B[2]), (2, B[1]), (1, B[0])] {
305        x = (x | (x << shift)) & mask;
306        y = (y | (y << shift)) & mask;
307    }
308    x | (y << 1)
309}
310
311/// Pull the two halves of an interleaved value back apart.
312#[must_use]
313fn deinterleave(bits: u64) -> (u32, u32) {
314    const B: [u64; 6] = [
315        0x5555_5555_5555_5555,
316        0x3333_3333_3333_3333,
317        0x0f0f_0f0f_0f0f_0f0f,
318        0x00ff_00ff_00ff_00ff,
319        0x0000_ffff_0000_ffff,
320        0x0000_0000_ffff_ffff,
321    ];
322    let mut x = bits;
323    let mut y = bits >> 1;
324    for (shift, mask) in [
325        (0, B[0]),
326        (1, B[1]),
327        (2, B[2]),
328        (4, B[3]),
329        (8, B[4]),
330        (16, B[5]),
331    ] {
332        x = (x | (x >> shift)) & mask;
333        y = (y | (y >> shift)) & mask;
334    }
335    (x as u32, y as u32)
336}
337
338/// Whether a point is somewhere the hash can name.
339#[must_use]
340pub fn in_range(lon: f64, lat: f64) -> bool {
341    (LON_MIN..=LON_MAX).contains(&lon) && (LAT_MIN..=LAT_MAX).contains(&lat)
342}
343
344/// The hash of a point at a given precision.
345///
346/// Nothing at all for a point outside the projection, which is the only way this
347/// fails. `step` is between 1 and [`STEP_MAX`].
348#[must_use]
349pub fn encode(lon: f64, lat: f64, step: u8) -> Option<Hash> {
350    encode_in(lon, lat, step, (LON_MIN, LON_MAX), (LAT_MIN, LAT_MAX))
351}
352
353/// The same, over ranges the caller picks.
354///
355/// This exists for one caller. A `GEOHASH` reply is a standard geohash string,
356/// and the standard runs latitude from -90 to 90 where we store it from
357/// -85.05112878, so the reply is the stored point decoded and then encoded again
358/// over the wider range. Everything else uses [`encode`].
359#[must_use]
360pub fn encode_in(
361    lon: f64,
362    lat: f64,
363    step: u8,
364    lon_range: (f64, f64),
365    lat_range: (f64, f64),
366) -> Option<Hash> {
367    if step == 0 || step > 32 || !in_range(lon, lat) {
368        return None;
369    }
370    let lat_offset = (lat - lat_range.0) / (lat_range.1 - lat_range.0);
371    let lon_offset = (lon - lon_range.0) / (lon_range.1 - lon_range.0);
372    let scale = (1u64 << step) as f64;
373    let bits = interleave((lat_offset * scale) as u32, (lon_offset * scale) as u32);
374    Some(Hash { bits, step })
375}
376
377/// The box a hash names.
378#[must_use]
379pub fn area(hash: Hash) -> Area {
380    let (ilat, ilon) = deinterleave(hash.bits);
381    let scale = (1u64 << hash.step) as f64;
382    let lon_scale = LON_MAX - LON_MIN;
383    let lat_scale = LAT_MAX - LAT_MIN;
384    Area {
385        lon: (
386            LON_MIN + (f64::from(ilon) / scale) * lon_scale,
387            LON_MIN + (f64::from(ilon + 1) / scale) * lon_scale,
388        ),
389        lat: (
390            LAT_MIN + (f64::from(ilat) / scale) * lat_scale,
391            LAT_MIN + (f64::from(ilat + 1) / scale) * lat_scale,
392        ),
393    }
394}
395
396/// A box's bits pushed up to where a full precision score keeps them.
397///
398/// A search asks the sorted set for scores between the aligned box and the
399/// aligned box after it, which is every point inside it.
400#[must_use]
401pub const fn align(hash: Hash) -> u64 {
402    hash.bits << (52 - hash.step * 2)
403}
404
405/// The score range a box covers, low inclusive and high exclusive.
406#[must_use]
407pub const fn range(hash: Hash) -> (u64, u64) {
408    let low = align(hash);
409    let high = align(Hash {
410        bits: hash.bits + 1,
411        step: hash.step,
412    });
413    (low, high)
414}
415
416/// The score a point is stored under.
417#[must_use]
418pub fn score(lon: f64, lat: f64) -> Option<u64> {
419    encode(lon, lat, STEP_MAX).map(align)
420}
421
422/// The point a score decodes to, which is the middle of the box it names.
423///
424/// This never fails on anything that came out of [`score`]. It takes an `f64`
425/// because that is what the sorted set holds and a score that is not a whole
426/// number in range was not written by us.
427///
428/// A score is fifty two bits everywhere except at the two far edges. A point at
429/// longitude 180 exactly scales to bucket `1 << 26`, which is one past the
430/// twenty six bits the axis has, and the same happens at the top latitude, so
431/// those two scores carry a fifty third and a fifty fourth bit. Redis stores
432/// them like that and reads them back the same way, so the limit here is what a
433/// full score can reach and not what it usually is. Refusing them left `GEOPOS`
434/// answering nothing for a point on the date line.
435#[must_use]
436pub fn decode(raw: f64) -> Option<(f64, f64)> {
437    if !raw.is_finite() || raw < 0.0 || raw >= (1u64 << 54) as f64 {
438        return None;
439    }
440    let bits = raw as u64;
441    if bits == 0 {
442        // Redis treats an all zero hash as undecodable, because zero is also
443        // what its "no such box" marker looks like. The point it would decode
444        // to is the far south west corner, which nothing real is at.
445        return None;
446    }
447    Some(
448        area(Hash {
449            bits,
450            step: STEP_MAX,
451        })
452        .centre(),
453    )
454}
455
456/// The arc between two latitudes, in metres.
457///
458/// The haversine with no longitude difference is `asin(sin(x))`, and latitude
459/// stays inside the range where that is just `x`, so this is the whole formula
460/// rather than a special case of it.
461#[must_use]
462pub fn lat_distance(lat1: f64, lat2: f64) -> f64 {
463    EARTH_RADIUS * ((lat2 - lat1) * DEG_TO_RAD).abs()
464}
465
466/// The great circle distance between two points, in metres.
467#[must_use]
468pub fn distance(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
469    let v = ((lon2 * DEG_TO_RAD - lon1 * DEG_TO_RAD) / 2.0).sin();
470    if v == 0.0 {
471        return lat_distance(lat1, lat2);
472    }
473    let (lat1r, lat2r) = (lat1 * DEG_TO_RAD, lat2 * DEG_TO_RAD);
474    let u = ((lat2r - lat1r) / 2.0).sin();
475    let a = u * u + lat1r.cos() * lat2r.cos() * v * v;
476    2.0 * EARTH_RADIUS * a.sqrt().asin()
477}
478
479/// How many bits of hash make a box roughly the size of a search.
480///
481/// Doubling the range until it covers the world counts the halvings, and then
482/// two are given back so the box is comfortably larger than the search rather
483/// than the same size as it. Near the poles a degree of longitude is short, so
484/// one or two more bits come off there.
485#[must_use]
486pub fn steps_for(mut metres: f64, lat: f64) -> u8 {
487    if metres == 0.0 {
488        return STEP_MAX;
489    }
490    let mut step = 1i32;
491    while metres < MERCATOR_MAX {
492        metres *= 2.0;
493        step += 1;
494    }
495    step -= 2;
496    if !(-66.0..=66.0).contains(&lat) {
497        step -= 1;
498        if !(-80.0..=80.0).contains(&lat) {
499            step -= 1;
500        }
501    }
502    step.clamp(1, i32::from(STEP_MAX)) as u8
503}
504
505/// Move a box one step east or west.
506///
507/// Longitude is the odd bits, so adding one to it means adding one at bit one
508/// with the even bits masked out of the way. Going the other way is the same
509/// trick with a borrow. It wraps at the edge of the world, which is what makes a
510/// search across the date line work without a special case.
511#[must_use]
512fn move_lon(hash: Hash, dir: i8) -> Hash {
513    if dir == 0 {
514        return hash;
515    }
516    let width = 64 - u32::from(hash.step) * 2;
517    let mut x = hash.bits & 0xaaaa_aaaa_aaaa_aaaa;
518    let y = hash.bits & 0x5555_5555_5555_5555;
519    let zz = 0x5555_5555_5555_5555u64 >> width;
520    if dir > 0 {
521        x = x.wrapping_add(zz + 1);
522    } else {
523        x |= zz;
524        x = x.wrapping_sub(zz + 1);
525    }
526    x &= 0xaaaa_aaaa_aaaa_aaaau64 >> width;
527    Hash {
528        bits: x | y,
529        step: hash.step,
530    }
531}
532
533/// Move a box one step north or south, which is the even bits.
534#[must_use]
535fn move_lat(hash: Hash, dir: i8) -> Hash {
536    if dir == 0 {
537        return hash;
538    }
539    let width = 64 - u32::from(hash.step) * 2;
540    let x = hash.bits & 0xaaaa_aaaa_aaaa_aaaa;
541    let mut y = hash.bits & 0x5555_5555_5555_5555;
542    let zz = 0xaaaa_aaaa_aaaa_aaaau64 >> width;
543    if dir > 0 {
544        y = y.wrapping_add(zz + 1);
545    } else {
546        y |= zz;
547        y = y.wrapping_sub(zz + 1);
548    }
549    y &= 0x5555_5555_5555_5555u64 >> width;
550    Hash {
551        bits: x | y,
552        step: hash.step,
553    }
554}
555
556/// The eight boxes around one, in the order a search walks them.
557///
558/// North, south, east, west, then the four corners, which is the order Redis
559/// uses and therefore the order an unsorted `GEOSEARCH` hands its results back
560/// in. Nobody should depend on that order and clients do, so it is kept.
561#[must_use]
562fn neighbours(hash: Hash) -> [Hash; 8] {
563    [
564        move_lat(hash, 1),
565        move_lat(hash, -1),
566        move_lon(hash, 1),
567        move_lon(hash, -1),
568        move_lon(move_lat(hash, 1), 1),
569        move_lon(move_lat(hash, 1), -1),
570        move_lon(move_lat(hash, -1), 1),
571        move_lon(move_lat(hash, -1), -1),
572    ]
573}
574
575/// The nine boxes a search over this shape has to look in.
576///
577/// Two corrections happen here and both matter. The estimated step can leave the
578/// side neighbours too small to reach past the search area when the centre sits
579/// near the edge of its own box, so the four sides are checked and the step
580/// drops by one if any of them falls short. And once the boxes are settled, a
581/// neighbour that the bounding box does not reach into at all is zeroed, which
582/// is usually three of the eight and is three range queries not run.
583#[must_use]
584pub fn areas(shape: &Shape) -> Search {
585    let bounds = shape.bounds();
586    let mut step = steps_for(shape.reach(), shape.lat);
587    let Some(mut hash) = encode(shape.lon, shape.lat, step) else {
588        return Search {
589            boxes: [Hash { bits: 0, step: 0 }; 9],
590            bounds,
591        };
592    };
593    let mut near = neighbours(hash);
594
595    let short = area(near[0]).lat.1 < bounds.lat.1
596        || area(near[1]).lat.0 > bounds.lat.0
597        || area(near[2]).lon.1 < bounds.lon.1
598        || area(near[3]).lon.0 > bounds.lon.0;
599    if step > 1 && short {
600        step -= 1;
601        hash = encode(shape.lon, shape.lat, step).unwrap_or(hash);
602        near = neighbours(hash);
603    }
604
605    // The order is fixed by `neighbours`: north, south, east, west, north east,
606    // north west, south east, south west.
607    if step >= 2 {
608        let own = area(hash);
609        let zero = Hash { bits: 0, step: 0 };
610        if own.lat.0 < bounds.lat.0 {
611            near[1] = zero;
612            near[7] = zero;
613            near[6] = zero;
614        }
615        if own.lat.1 > bounds.lat.1 {
616            near[0] = zero;
617            near[4] = zero;
618            near[5] = zero;
619        }
620        if own.lon.0 < bounds.lon.0 {
621            near[3] = zero;
622            near[7] = zero;
623            near[5] = zero;
624        }
625        if own.lon.1 > bounds.lon.1 {
626            near[2] = zero;
627            near[6] = zero;
628            near[4] = zero;
629        }
630    }
631
632    Search {
633        boxes: [
634            hash, near[0], near[1], near[2], near[3], near[4], near[5], near[6], near[7],
635        ],
636        bounds,
637    }
638}
639
640/// The eleven character geohash string for a point.
641///
642/// The string is the standard one, so latitude runs from -90 to 90 here rather
643/// than from the Mercator limit the score uses. Eleven characters is 55 bits and
644/// there are only 52, so the last character is always `0`. Redis has written it
645/// that way since the command existed and a client that parses the string back
646/// has to see the same thing.
647#[must_use]
648pub fn geohash(lon: f64, lat: f64) -> Option<[u8; HASH_CHARS]> {
649    let hash = encode_in(lon, lat, STEP_MAX, (LON_MIN, LON_MAX), (-90.0, 90.0))?;
650    let mut out = [b'0'; HASH_CHARS];
651    for (i, slot) in out.iter_mut().enumerate().take(HASH_CHARS - 1) {
652        let idx = (hash.bits >> (52 - (i + 1) * 5)) & 0x1f;
653        *slot = ALPHABET[idx as usize];
654    }
655    Some(out)
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    /// The two scores in every piece of Redis documentation, and the ones a real
663    /// 8.10.1 answers for `ZSCORE` after the `GEOADD` from its own manual page.
664    const PALERMO: (f64, f64, u64) = (13.361389, 38.115556, 3_479_099_956_230_698);
665    const CATANIA: (f64, f64, u64) = (15.087269, 37.502669, 3_479_447_370_796_909);
666
667    #[test]
668    fn a_point_scores_what_a_real_server_scores_it() {
669        assert_eq!(score(PALERMO.0, PALERMO.1), Some(PALERMO.2));
670        assert_eq!(score(CATANIA.0, CATANIA.1), Some(CATANIA.2));
671    }
672
673    #[test]
674    fn a_score_decodes_back_to_where_the_point_nearly_was() {
675        // Not exactly where it was. Twenty six bits of latitude is about two
676        // metres, so a stored point comes back as the middle of the box it
677        // landed in, and these are the digits a real server prints.
678        let (lon, lat) = decode(PALERMO.2 as f64).expect("a real score decodes");
679        assert_eq!(format!("{lon}"), "13.361389338970184");
680        assert_eq!(format!("{lat}"), "38.1155563954963");
681        let (lon, lat) = decode(CATANIA.2 as f64).expect("a real score decodes");
682        assert_eq!(format!("{lon}"), "15.087267458438873");
683        assert_eq!(format!("{lat}"), "37.50266842333162");
684    }
685
686    #[test]
687    fn a_point_outside_the_projection_has_no_score() {
688        assert_eq!(score(181.0, 38.0), None);
689        assert_eq!(score(13.0, 86.0), None);
690        assert_eq!(score(-180.1, 0.0), None);
691        // The limit itself is inside.
692        assert!(score(180.0, LAT_MAX).is_some());
693        assert!(score(-180.0, LAT_MIN).is_some());
694    }
695
696    #[test]
697    fn interleaving_is_its_own_inverse() {
698        for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (0x03ff_ffff, 0x0155_5555)] {
699            assert_eq!(deinterleave(interleave(x, y)), (x, y));
700        }
701    }
702
703    #[test]
704    fn the_distances_are_the_ones_a_real_server_answers() {
705        let d = distance(PALERMO.0, PALERMO.1, CATANIA.0, CATANIA.1);
706        // Redis answers 166274.1516 for these two, from the decoded positions
707        // rather than the ones that were sent, which is what this measures.
708        let (a, b) = (
709            decode(PALERMO.2 as f64).expect("a score"),
710            decode(CATANIA.2 as f64).expect("a score"),
711        );
712        let stored = distance(a.0, a.1, b.0, b.1);
713        assert_eq!(format!("{stored:.4}"), "166274.1516");
714        assert_eq!(format!("{:.4}", stored / Unit::Km.metres()), "166.2742");
715        assert_eq!(format!("{:.4}", stored / Unit::Mi.metres()), "103.3182");
716        assert_eq!(format!("{:.4}", stored / Unit::Ft.metres()), "545518.8700");
717        // The sent positions are within a couple of metres of the stored ones,
718        // which is the whole error budget of a 52 bit hash.
719        assert!((d - stored).abs() < 3.0, "{d} against {stored}");
720    }
721
722    #[test]
723    fn two_points_on_one_meridian_take_the_short_path() {
724        // Both points store under the same longitude, so the shortcut is the
725        // one that runs and it has to answer what a real server answers for the
726        // same pair, which is 111226.3808 metres.
727        let a = decode(score(10.0, 40.0).expect("in range") as f64).expect("a score");
728        let b = decode(score(10.0, 41.0).expect("in range") as f64).expect("a score");
729        assert_eq!(a.0, b.0);
730        let d = distance(a.0, a.1, b.0, b.1);
731        assert_eq!(format!("{d:.4}"), "111226.3808");
732        assert_eq!(lat_distance(a.1, b.1), d);
733    }
734
735    #[test]
736    fn the_geohash_strings_are_the_ones_a_real_server_writes() {
737        let (lon, lat) = decode(PALERMO.2 as f64).expect("a score");
738        assert_eq!(&geohash(lon, lat).expect("in range"), b"sqc8b49rny0");
739        let (lon, lat) = decode(CATANIA.2 as f64).expect("a score");
740        assert_eq!(&geohash(lon, lat).expect("in range"), b"sqdtr74hyu0");
741    }
742
743    #[test]
744    fn a_unit_is_read_in_any_case_and_nothing_else_is() {
745        assert_eq!(Unit::parse(b"m"), Some(Unit::M));
746        assert_eq!(Unit::parse(b"KM"), Some(Unit::Km));
747        assert_eq!(Unit::parse(b"Ft"), Some(Unit::Ft));
748        assert_eq!(Unit::parse(b"mI"), Some(Unit::Mi));
749        assert_eq!(Unit::parse(b"yd"), None);
750        assert_eq!(Unit::parse(b"meters"), None);
751        assert_eq!(Unit::parse(b""), None);
752    }
753
754    #[test]
755    fn a_box_covers_the_scores_of_everything_inside_it() {
756        // Every point in the box has a score in the range, and the range is half
757        // open, so the box after it starts exactly where this one ends.
758        let hash = encode(13.0, 38.0, 10).expect("in range");
759        let (low, high) = range(hash);
760        let inside = score(13.0, 38.0).expect("in range");
761        assert!(low <= inside && inside < high);
762        let next = range(Hash {
763            bits: hash.bits + 1,
764            step: hash.step,
765        });
766        assert_eq!(high, next.0);
767    }
768
769    #[test]
770    fn the_step_estimate_shrinks_the_box_as_the_radius_grows() {
771        // A tiny radius gets the finest boxes and a global one gets the coarsest.
772        assert_eq!(steps_for(0.0, 0.0), STEP_MAX);
773        assert!(steps_for(1.0, 0.0) > steps_for(1000.0, 0.0));
774        assert!(steps_for(1000.0, 0.0) > steps_for(1_000_000.0, 0.0));
775        assert_eq!(steps_for(40_000_000.0, 0.0), 1);
776        // Nearer the poles the boxes are coarser for the same radius, because a
777        // degree of longitude is shorter there.
778        assert_eq!(steps_for(1000.0, 70.0), steps_for(1000.0, 0.0) - 1);
779        assert_eq!(steps_for(1000.0, 85.0), steps_for(1000.0, 0.0) - 2);
780    }
781
782    #[test]
783    fn the_neighbours_of_a_box_are_the_eight_boxes_around_it() {
784        let hash = encode(13.0, 38.0, 10).expect("in range");
785        let own = area(hash);
786        let near = neighbours(hash);
787        // North is the same longitude one step up in latitude, and the two boxes
788        // meet with no gap between them.
789        assert_eq!(area(near[0]).lat.0, own.lat.1);
790        assert_eq!(area(near[1]).lat.1, own.lat.0);
791        assert_eq!(area(near[2]).lon.0, own.lon.1);
792        assert_eq!(area(near[3]).lon.1, own.lon.0);
793        // The corners agree with the two sides they came from.
794        assert_eq!(area(near[4]).lat.0, own.lat.1);
795        assert_eq!(area(near[4]).lon.0, own.lon.1);
796    }
797
798    #[test]
799    fn the_boxes_around_the_date_line_wrap_rather_than_run_out() {
800        let hash = encode(179.99, 0.0, 6).expect("in range");
801        let east = neighbours(hash)[2];
802        // East of the last box is the first one, which is what makes a search
803        // across the date line find anything at all.
804        assert!(area(east).lon.0 < area(hash).lon.0);
805    }
806
807    #[test]
808    fn a_search_keeps_the_boxes_the_area_reaches_and_drops_the_rest() {
809        let shape = Shape {
810            lon: 15.0,
811            lat: 37.0,
812            kind: Kind::Circle { radius: 200.0 },
813            unit: Unit::Km,
814        };
815        let search = areas(&shape);
816        // The centre box is always searched, and at least one neighbour was
817        // ruled out, since a circle cannot reach into all eight.
818        assert_ne!(search.boxes[0].bits, 0);
819        assert!(search.boxes[1..].iter().any(|h| h.bits == 0));
820        // Every box that survived overlaps the bounding rectangle.
821        for h in &search.boxes {
822            if h.bits == 0 && h.step == 0 {
823                continue;
824            }
825            let a = area(*h);
826            assert!(a.lon.1 >= search.bounds.lon.0 && a.lon.0 <= search.bounds.lon.1);
827            assert!(a.lat.1 >= search.bounds.lat.0 && a.lat.0 <= search.bounds.lat.1);
828        }
829    }
830
831    #[test]
832    fn a_shape_covers_what_is_inside_it_and_nothing_else() {
833        let circle = Shape {
834            lon: 15.0,
835            lat: 37.0,
836            kind: Kind::Circle { radius: 100.0 },
837            unit: Unit::Km,
838        };
839        assert!(circle.covers(15.0, 37.0).is_some());
840        assert!(circle.covers(15.0, 37.5).is_some());
841        assert!(circle.covers(15.0, 39.0).is_none());
842        // A rectangle is not the circle that contains it, so the corner of the
843        // bounding square is outside the circle and inside the box.
844        let rect = Shape {
845            lon: 15.0,
846            lat: 37.0,
847            kind: Kind::Rect {
848                width: 200.0,
849                height: 200.0,
850            },
851            unit: Unit::Km,
852        };
853        let corner = (15.0 + 1.1, 37.0 + 0.85);
854        assert!(rect.covers(corner.0, corner.1).is_some());
855        assert!(circle.covers(corner.0, corner.1).is_none());
856    }
857
858    #[test]
859    fn a_score_nothing_wrote_does_not_decode() {
860        assert_eq!(decode(-1.0), None);
861        assert_eq!(decode(0.0), None);
862        assert_eq!(decode(f64::NAN), None);
863        assert_eq!(decode(f64::INFINITY), None);
864        assert_eq!(decode((1u64 << 54) as f64), None);
865        // Not this one though. A point on the top edge of an axis scales to one
866        // bucket past that axis, so the score carries a fifty third bit for
867        // longitude and a fifty fourth for latitude, and Redis reads both back.
868        // `ZADD k 4503599627370496 m` then `GEOPOS k m` answers the top of the
869        // projection rather than nothing.
870        assert_eq!(
871            decode((1u64 << 52) as f64),
872            Some((-179.999_997_317_790_99, LAT_MAX))
873        );
874        assert_eq!(
875            decode((1u64 << 53) as f64),
876            Some((LON_MAX, -85.051_127_512_639_43))
877        );
878        // A score with a fraction in it is truncated rather than refused, the
879        // same as Redis's cast does, so `ZADD k 1.5 m` then `GEOPOS k m` answers
880        // the point score 1 names rather than an error.
881        assert!(decode(1.5).is_some());
882    }
883}