Skip to main content

yo_kv/
geo.rs

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