274 lines
11 KiB
Rust
274 lines
11 KiB
Rust
//! Offline reverse geocoding: a GPS position → Sublocation / City / State / Country, from the
|
|
//! GeoNames dump (CC-BY 4.0, "GeoNames, geonames.org"). See README.md.
|
|
//!
|
|
//! Sources: GeoNames dump readme (prose); haversine (textbook); cell index own design.
|
|
#![forbid(unsafe_code)]
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod unzip;
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
use serde::Serialize;
|
|
|
|
/// Lookups farther than this from any place find nothing.
|
|
pub const MAX_KM: f64 = 50.0;
|
|
/// The files the engine downloads (the first comes zipped as `cities15000.zip`).
|
|
pub const GEONAMES_FILES: [&str; 3] = ["cities15000.txt", "admin1CodesASCII.txt", "countryInfo.txt"];
|
|
/// The zip holding [`GEONAMES_FILES`]`[0]`.
|
|
pub const GEONAMES_ZIP: &str = "cities15000.zip";
|
|
pub const GEONAMES_BASE: &str = "https://download.geonames.org/export/dump/";
|
|
pub const GEONAMES_ATTRIBUTION: &str = "Place names: GeoNames (geonames.org), CC-BY 4.0";
|
|
/// Places kept from a dataset (cities15000 has ~33 000; cities500 ~200 000).
|
|
const MAX_PLACES: usize = 2_000_000;
|
|
/// Largest name kept (GeoNames names are short; anything longer is junk).
|
|
const MAX_NAME: usize = 200;
|
|
const EARTH_RADIUS_M: f64 = 6_371_008.8;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum GeocodeError {
|
|
/// The place names aren't there (not downloaded yet) or can't be read.
|
|
Missing(String),
|
|
/// The data is not what it should be.
|
|
Invalid(String),
|
|
}
|
|
|
|
impl std::fmt::Display for GeocodeError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
GeocodeError::Missing(e) | GeocodeError::Invalid(e) => f.write_str(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for GeocodeError {}
|
|
|
|
/// A position in degrees.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct LatLon {
|
|
pub lat: f64,
|
|
pub lon: f64,
|
|
}
|
|
|
|
impl LatLon {
|
|
pub fn new(lat: f64, lon: f64) -> LatLon {
|
|
LatLon { lat, lon }
|
|
}
|
|
|
|
/// Finite and on the globe.
|
|
pub fn is_valid(self) -> bool {
|
|
self.lat.is_finite() && self.lon.is_finite() && self.lat.abs() <= 90.0 && self.lon.abs() <= 180.0
|
|
}
|
|
}
|
|
|
|
/// Great-circle distance in metres (haversine). NaN in, NaN out.
|
|
pub fn distance_m(a: LatLon, b: LatLon) -> f64 {
|
|
let (p1, p2) = (a.lat.to_radians(), b.lat.to_radians());
|
|
let dp = p2 - p1;
|
|
let dl = (b.lon - a.lon).to_radians();
|
|
let h = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2);
|
|
2.0 * EARTH_RADIUS_M * h.clamp(0.0, 1.0).sqrt().asin()
|
|
}
|
|
|
|
/// What a lookup fills in (empty strings: unknown).
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
|
pub struct Place {
|
|
/// Below city level. GeoNames' city list has none, so offline lookups leave it empty.
|
|
pub sublocation: String,
|
|
pub city: String,
|
|
pub state: String,
|
|
pub country: String,
|
|
/// ISO 3166-1 alpha-2, upper case.
|
|
pub iso: String,
|
|
/// Metres from the position to the place.
|
|
pub distance: f64,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct City {
|
|
name: String,
|
|
at: LatLon,
|
|
country: String,
|
|
admin1: String,
|
|
}
|
|
|
|
/// An offline GeoNames index: places bucketed by 1° cells.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct GeoNames {
|
|
cities: Vec<City>,
|
|
cells: HashMap<(i32, i32), Vec<u32>>,
|
|
admin1: HashMap<String, String>,
|
|
countries: HashMap<String, String>,
|
|
}
|
|
|
|
fn cell(p: LatLon) -> (i32, i32) {
|
|
// valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range
|
|
(p.lat.floor() as i32, p.lon.floor() as i32)
|
|
}
|
|
|
|
fn short(s: &str) -> String {
|
|
s.trim().chars().take(MAX_NAME).collect()
|
|
}
|
|
|
|
impl GeoNames {
|
|
/// From the dump's text: `cities` (tab-separated, the `cities*.txt` layout), and optionally
|
|
/// `admin1CodesASCII.txt` and `countryInfo.txt`. Bad lines are skipped.
|
|
pub fn parse(cities: &str, admin1: Option<&str>, countries: Option<&str>) -> GeoNames {
|
|
let mut g = GeoNames::default();
|
|
for line in cities.lines() {
|
|
if g.cities.len() >= MAX_PLACES {
|
|
break;
|
|
}
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
// columns: 1 name, 4 latitude, 5 longitude, 8 country code, 10 admin1 code
|
|
let (Some(name), Some(lat), Some(lon), Some(cc)) = (f.get(1), f.get(4), f.get(5), f.get(8)) else { continue };
|
|
let (Ok(lat), Ok(lon)) = (lat.trim().parse::<f64>(), lon.trim().parse::<f64>()) else { continue };
|
|
let at = LatLon::new(lat, lon);
|
|
let name = short(name);
|
|
if !at.is_valid() || name.is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(i) = u32::try_from(g.cities.len()) else { break };
|
|
g.cells.entry(cell(at)).or_default().push(i);
|
|
g.cities.push(City { name, at, country: short(cc).to_ascii_uppercase(), admin1: f.get(10).map(|s| short(s)).unwrap_or_default() });
|
|
}
|
|
for line in admin1.unwrap_or("").lines() {
|
|
let mut f = line.split('\t');
|
|
if let (Some(code), Some(name)) = (f.next(), f.next()) {
|
|
g.admin1.insert(short(code), short(name));
|
|
}
|
|
}
|
|
for line in countries.unwrap_or("").lines().filter(|l| !l.starts_with('#')) {
|
|
let f: Vec<&str> = line.split('\t').collect();
|
|
if let (Some(iso), Some(name)) = (f.first(), f.get(4)) {
|
|
g.countries.insert(short(iso).to_ascii_uppercase(), short(name));
|
|
}
|
|
}
|
|
g
|
|
}
|
|
|
|
/// Load from a folder holding [`GEONAMES_FILES`] (the last two are optional: without them
|
|
/// states stay blank and countries are ISO codes).
|
|
pub fn load(dir: &Path) -> Result<GeoNames, GeocodeError> {
|
|
let read = |n: &str| std::fs::read(dir.join(n)).map(|b| String::from_utf8_lossy(&b).into_owned());
|
|
let cities = read(GEONAMES_FILES[0]).map_err(|e| GeocodeError::Missing(format!("the place names aren't downloaded ({e})")))?;
|
|
let g = GeoNames::parse(&cities, read(GEONAMES_FILES[1]).ok().as_deref(), read(GEONAMES_FILES[2]).ok().as_deref());
|
|
if g.is_empty() {
|
|
return Err(GeocodeError::Invalid("the place names file holds no places".into()));
|
|
}
|
|
Ok(g)
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.cities.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.cities.is_empty()
|
|
}
|
|
|
|
/// The nearest place within [`MAX_KM`]; `None` for an invalid position or open country.
|
|
pub fn reverse(&self, p: LatLon) -> Option<Place> {
|
|
if !p.is_valid() {
|
|
return None;
|
|
}
|
|
let (cy, cx) = cell(p);
|
|
let mut best: Option<(f64, &City)> = None;
|
|
// ±1° covers 50 km everywhere except very near the poles (where there are no cities)
|
|
for dy in -1..=1 {
|
|
for dx in -1..=1 {
|
|
let mut x = cx + dx;
|
|
if x < -180 {
|
|
x += 360;
|
|
} else if x >= 180 {
|
|
x -= 360;
|
|
}
|
|
for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() {
|
|
let Some(c) = self.cities.get(i as usize) else { continue };
|
|
let d = distance_m(p, c.at);
|
|
if best.is_none_or(|(b, _)| d < b) {
|
|
best = Some((d, c));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let (d, c) = best?;
|
|
(d <= MAX_KM * 1000.0).then(|| Place {
|
|
sublocation: String::new(),
|
|
city: c.name.clone(),
|
|
state: self.admin1.get(&format!("{}.{}", c.country, c.admin1)).cloned().unwrap_or_default(),
|
|
country: self.countries.get(&c.country).cloned().unwrap_or_else(|| c.country.clone()),
|
|
iso: c.country.clone(),
|
|
distance: d.round(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const CITIES: &str = "2988507\tParis\tParis\t\t48.85341\t2.3488\tP\tPPLC\tFR\t\t11\t75\t\t\t2138551\t\t42\tEurope/Paris\t2024-01-01\n\
|
|
2643743\tLondon\tLondon\t\t51.50853\t-0.12574\tP\tPPLC\tGB\t\tENG\t\t\t\t8961989\t\t25\tEurope/London\t2024-01-01\n\
|
|
bad line\n\
|
|
1\tNowhere\tx\t\tNaN\t0\tP\tP\tXX\n\
|
|
2\tFar\tx\t\t500\t0\tP\tP\tXX\n\
|
|
3\tInf\tx\t\tinf\t-inf\tP\tP\tXX\n";
|
|
|
|
#[test]
|
|
fn nearest_place() {
|
|
let g = GeoNames::parse(CITIES, Some("FR.11\tÎle-de-France\nGB.ENG\tEngland\n"), Some("#ISO\tx\nFR\tFRA\t250\tFR\tFrance\n"));
|
|
assert_eq!(g.len(), 2);
|
|
let p = g.reverse(LatLon::new(48.86, 2.34)).unwrap();
|
|
assert_eq!((p.city.as_str(), p.state.as_str(), p.country.as_str(), p.iso.as_str()), ("Paris", "Île-de-France", "France", "FR"));
|
|
assert!(p.distance > 0.0 && p.distance < 2000.0);
|
|
// no countryInfo row: the ISO code stands in
|
|
let l = g.reverse(LatLon::new(51.5, -0.1)).unwrap();
|
|
assert_eq!((l.city.as_str(), l.state.as_str(), l.country.as_str()), ("London", "England", "GB"));
|
|
// open sea
|
|
assert!(g.reverse(LatLon::new(0.0, 0.0)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_positions_find_nothing() {
|
|
let g = GeoNames::parse(CITIES, None, None);
|
|
for (la, lo) in [(f64::NAN, 0.0), (0.0, f64::NAN), (f64::INFINITY, 0.0), (91.0, 0.0), (0.0, -180.5), (-1e300, 1e300)] {
|
|
assert!(g.reverse(LatLon::new(la, lo)).is_none(), "{la}, {lo}");
|
|
}
|
|
// the poles and the antimeridian are fine
|
|
assert!(g.reverse(LatLon::new(90.0, 180.0)).is_none());
|
|
assert!(g.reverse(LatLon::new(-90.0, -180.0)).is_none());
|
|
assert!(GeoNames::default().reverse(LatLon::new(48.86, 2.34)).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn across_the_antimeridian() {
|
|
let fiji = GeoNames::parse("1\tX\tX\t\t-17.0\t179.9\tP\tP\tFJ\t\t\n", None, None);
|
|
assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X");
|
|
}
|
|
|
|
#[test]
|
|
fn junk_text_parses_to_nothing() {
|
|
let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x"));
|
|
assert!(g.is_empty());
|
|
let long = format!("1\t{}\tx\t\t1\t1\tP\tP\tXX\n", "a".repeat(10_000));
|
|
assert_eq!(GeoNames::parse(&long, None, None).reverse(LatLon::new(1.0, 1.0)).unwrap().city.len(), MAX_NAME);
|
|
}
|
|
|
|
#[test]
|
|
fn load_from_a_folder() {
|
|
let dir = std::env::temp_dir().join(format!("lightcraft-geocode-load-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Missing(_))));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join(GEONAMES_FILES[0]), "junk").unwrap();
|
|
assert!(matches!(GeoNames::load(&dir), Err(GeocodeError::Invalid(_))));
|
|
std::fs::write(dir.join(GEONAMES_FILES[0]), CITIES).unwrap();
|
|
std::fs::write(dir.join(GEONAMES_FILES[2]), "GB\tGBR\t826\tUK\tUnited Kingdom\n").unwrap();
|
|
let g = GeoNames::load(&dir).unwrap();
|
|
assert_eq!(g.reverse(LatLon::new(51.5, -0.1)).unwrap().country, "United Kingdom");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|