use chrono::{DateTime, Utc};
use thiserror::Error;
const BITS_PER_DIMENSION: u8 = 21; const MAX_VALUE: f64 = ((1u64 << BITS_PER_DIMENSION) - 1) as f64;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub struct Hasher {
start_datetime: DateTime<Utc>,
end_datetime: DateTime<Utc>,
datetime_range_millis: f64,
min_longitude: f64,
max_longitude: f64,
longitude_range: f64,
min_latitude: f64,
max_latitude: f64,
latitude_range: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct Point {
pub longitude: f64,
pub latitude: f64,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("datetime outside of the hasher's temporal extent: {0}")]
InvalidDatetime(DateTime<Utc>),
#[error("latitude outside of the hasher's spatial extent: {0}")]
InvalidLatitude(f64),
#[error("longitude outside of the hasher's spatial extent: {0}")]
InvalidLongitude(f64),
}
impl Hasher {
pub fn global(start_datetime: DateTime<Utc>, end_datetime: DateTime<Utc>) -> Result<Self> {
Self::new(start_datetime, end_datetime, (-180., -90.), (180., 90.))
}
pub fn new(
start_datetime: DateTime<Utc>,
end_datetime: DateTime<Utc>,
min: impl Into<Point>,
max: impl Into<Point>,
) -> Result<Self> {
let min = min.into();
let max = max.into();
let datetime_range_millis = (end_datetime - start_datetime).num_milliseconds();
let longitude_range = max.longitude - min.longitude;
let latitude_range = max.latitude - min.latitude;
Ok(Self {
start_datetime,
end_datetime,
datetime_range_millis: datetime_range_millis as f64,
min_longitude: min.longitude,
max_longitude: max.longitude,
longitude_range,
min_latitude: min.latitude,
max_latitude: max.latitude,
latitude_range,
})
}
pub fn hash(&self, datetime: DateTime<Utc>, point: impl Into<Point>) -> Result<u64> {
let point = point.into();
if datetime < self.start_datetime || datetime > self.end_datetime {
return Err(Error::InvalidDatetime(datetime));
}
if point.latitude < self.min_latitude || point.latitude > self.max_latitude {
return Err(Error::InvalidLatitude(point.latitude));
}
if point.longitude < self.min_longitude || point.longitude > self.max_longitude {
return Err(Error::InvalidLongitude(point.longitude));
}
Ok(self.interleave(datetime, point))
}
pub fn hash_clamped(&self, datetime: DateTime<Utc>, point: impl Into<Point>) -> u64 {
let point = point.into();
if datetime < self.start_datetime || datetime > self.end_datetime {
log::warn!("datetime outside of the hasher's temporal extent: {datetime}");
}
if point.latitude < self.min_latitude || point.latitude > self.max_latitude {
log::warn!(
"latitude outside of the hasher's spatial extent: {}",
point.latitude
);
}
if point.longitude < self.min_longitude || point.longitude > self.max_longitude {
log::warn!(
"longitude outside of the hasher's spatial extent: {}",
point.longitude
);
}
self.interleave(datetime, point)
}
fn interleave(&self, datetime: DateTime<Utc>, point: Point) -> u64 {
let datetime_normalized = (((datetime.timestamp_millis()
- self.start_datetime.timestamp_millis()) as f64)
/ self.datetime_range_millis)
.clamp(0., 1.);
let latitude_normalized =
((point.latitude - self.min_latitude) / self.latitude_range).clamp(0., 1.);
let longitude_normalized =
((point.longitude - self.min_longitude) / self.longitude_range).clamp(0., 1.);
let datetime_quantized = (datetime_normalized * MAX_VALUE) as u64;
let latitude_quantized = (latitude_normalized * MAX_VALUE) as u64;
let longitude_quantized = (longitude_normalized * MAX_VALUE) as u64;
let mut hash = 0u64;
for i in 0..BITS_PER_DIMENSION {
let src = i as u64;
let dst = (i as u64) * 3;
hash |= ((longitude_quantized >> src) & 1) << dst;
hash |= ((latitude_quantized >> src) & 1) << (dst + 1);
hash |= ((datetime_quantized >> src) & 1) << (dst + 2);
}
hash
}
}
impl From<(f64, f64)> for Point {
fn from((longitude, latitude): (f64, f64)) -> Self {
Self {
longitude,
latitude,
}
}
}
#[cfg(test)]
mod tests {
use crate::Point;
use super::Hasher;
use chrono::{DateTime, TimeZone, Utc};
use rstest::{fixture, rstest};
#[fixture]
fn start_datetime() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
}
#[fixture]
fn end_datetime() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap()
}
#[fixture]
fn longmont() -> Point {
Point {
longitude: -105.,
latitude: 40.,
}
}
#[fixture]
fn hasher(start_datetime: DateTime<Utc>, end_datetime: DateTime<Utc>) -> Hasher {
Hasher::global(start_datetime, end_datetime).unwrap()
}
#[rstest]
fn one_year_global(hasher: Hasher, longmont: Point) {
let hash = hasher
.hash(
Utc.with_ymd_and_hms(2026, 6, 14, 12, 0, 0).unwrap(),
longmont,
)
.unwrap();
assert_eq!(hash, 3024785829217804842);
}
#[fixture]
fn colorado(start_datetime: DateTime<Utc>, end_datetime: DateTime<Utc>) -> Hasher {
Hasher::new(start_datetime, end_datetime, (-109., 37.), (-102., 41.)).unwrap()
}
#[rstest]
fn hash_clamped_matches_hash_inside_the_extent(
colorado: Hasher,
start_datetime: DateTime<Utc>,
longmont: Point,
) {
assert_eq!(
colorado.hash_clamped(start_datetime, longmont),
colorado.hash(start_datetime, longmont).unwrap()
);
}
#[rstest]
fn hash_clamped_clamps_longitude(colorado: Hasher, start_datetime: DateTime<Utc>) {
assert_eq!(
colorado.hash_clamped(start_datetime, (-120., 40.)),
colorado.hash(start_datetime, (-109., 40.)).unwrap()
);
}
#[rstest]
fn hash_clamped_clamps_latitude(colorado: Hasher, start_datetime: DateTime<Utc>) {
assert_eq!(
colorado.hash_clamped(start_datetime, (-105., 90.)),
colorado.hash(start_datetime, (-105., 41.)).unwrap()
);
}
#[rstest]
fn hash_clamped_clamps_datetime(
colorado: Hasher,
end_datetime: DateTime<Utc>,
longmont: Point,
) {
let beyond = end_datetime + chrono::Duration::days(365);
assert_eq!(
colorado.hash_clamped(beyond, longmont),
colorado.hash(end_datetime, longmont).unwrap()
);
}
#[rstest]
fn hash_still_errors_outside_the_extent(colorado: Hasher, start_datetime: DateTime<Utc>) {
assert!(colorado.hash(start_datetime, (-120., 40.)).is_err());
assert!(colorado.hash(start_datetime, (-105., 90.)).is_err());
assert!(
colorado
.hash(start_datetime - chrono::Duration::days(1), (-105., 40.))
.is_err()
);
}
#[rstest]
fn nearby_points_have_close_hashes(hasher: Hasher, start_datetime: DateTime<Utc>) {
let hash = hasher.hash(start_datetime, (-105., 40.)).unwrap();
let hash_near = hasher.hash(start_datetime, (-105.1, 40.1)).unwrap();
let hash_far = hasher.hash(start_datetime, (-106., 41.)).unwrap();
assert!(hash.abs_diff(hash_near) < hash.abs_diff(hash_far));
}
#[rstest]
fn sort_datetime(hasher: Hasher, start_datetime: DateTime<Utc>, longmont: Point) {
let hash_a = hasher.hash(start_datetime, longmont).unwrap();
let hash_b = hasher
.hash(start_datetime + chrono::Duration::days(1), longmont)
.unwrap();
assert!(hash_a < hash_b);
}
#[rstest]
fn sort_latitude(hasher: Hasher, start_datetime: DateTime<Utc>, longmont: Point) {
let hash_a = hasher.hash(start_datetime, longmont).unwrap();
let hash_b = hasher
.hash(
start_datetime,
Point {
latitude: 41.,
longitude: -105.,
},
)
.unwrap();
assert!(hash_a < hash_b);
}
#[rstest]
fn sort_longitude(hasher: Hasher, start_datetime: DateTime<Utc>, longmont: Point) {
let hash_a = hasher.hash(start_datetime, longmont).unwrap();
let hash_b = hasher
.hash(
start_datetime,
Point {
latitude: 40.,
longitude: -104.,
},
)
.unwrap();
assert!(hash_a < hash_b);
}
}