lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 3d795866d1da9333b5bee3f740c9675d7e89645e
parent 52b4a3028d5559848de5720988b3f2dd4d0ebbdb
Author: triesap <tyson@radroots.org>
Date:   Sat, 21 Mar 2026 21:01:24 +0000

geocoder: add offline geonames queries

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 2++
Mcontract/coverage/policy.toml | 1+
Mcontract/release/publish-set.toml | 2++
Acrates/geocoder/Cargo.toml | 25+++++++++++++++++++++++++
Acrates/geocoder/README.md | 3+++
Acrates/geocoder/src/error.rs | 9+++++++++
Acrates/geocoder/src/geocoder.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/geocoder/src/lib.rs | 11+++++++++++
Acrates/geocoder/src/model.rs | 42++++++++++++++++++++++++++++++++++++++++++
Acrates/geocoder/tests/geocoder.rs | 286+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 550 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2151,6 +2151,16 @@ dependencies = [ ] [[package]] +name = "radroots-geocoder" +version = "0.1.0-alpha.1" +dependencies = [ + "rusqlite", + "serde", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] name = "radroots-identity" version = "0.1.0-alpha.1" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/events-codec", "crates/events-codec-wasm", "crates/events-indexed", + "crates/geocoder", "crates/identity", "crates/log", "crates/net", @@ -44,6 +45,7 @@ radroots-core = { path = "crates/core", version = "0.1.0-alpha.1", default-featu radroots-events = { path = "crates/events", version = "0.1.0-alpha.1", default-features = false } radroots-events-codec = { path = "crates/events-codec", version = "0.1.0-alpha.1", default-features = false } radroots-events-indexed = { path = "crates/events-indexed", version = "0.1.0-alpha.1", default-features = false } +radroots-geocoder = { path = "crates/geocoder", version = "0.1.0-alpha.1" } radroots-identity = { path = "crates/identity", version = "0.1.0-alpha.1", default-features = false } radroots-nostr = { path = "crates/nostr", version = "0.1.0-alpha.1", default-features = false } radroots-nostr-accounts = { path = "crates/nostr-accounts", version = "0.1.0-alpha.1", default-features = false } diff --git a/contract/coverage/policy.toml b/contract/coverage/policy.toml @@ -17,6 +17,7 @@ crates = [ "radroots-replica-db-schema", "xtask", "radroots-events-indexed", + "radroots-geocoder", "radroots-log", "radroots-net-core", "radroots-net", diff --git a/contract/release/publish-set.toml b/contract/release/publish-set.toml @@ -13,6 +13,7 @@ crates = [ "radroots-events-codec", "radroots-events-codec-wasm", "radroots-events-indexed", + "radroots-geocoder", "radroots-nostr", "radroots-nostr-connect", "radroots-nostr-signer", @@ -46,6 +47,7 @@ crates = [ "radroots-trade", "radroots-events-codec-wasm", "radroots-events-indexed", + "radroots-geocoder", "radroots-nostr", "radroots-nostr-connect", "radroots-nostr-signer", diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "radroots-geocoder" +version = "0.1.0-alpha.1" +edition.workspace = true +authors = [ + "Radroots Authors", +] +rust-version.workspace = true +license.workspace = true +description = "offline geocoder queries for radroots geonames datasets" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots-geocoder" +readme.workspace = true + +[dependencies] +rusqlite = { workspace = true, features = ["bundled", "serialize"] } +serde = { workspace = true, features = ["derive"] } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/geocoder/README.md b/crates/geocoder/README.md @@ -0,0 +1,3 @@ +# radroots-geocoder + +Offline geocoder queries for the Rad Roots `geonames.db` dataset. diff --git a/crates/geocoder/src/error.rs b/crates/geocoder/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GeocoderError { + #[error("sqlite error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("country center not found for {country_id}")] + CountryCenterNotFound { country_id: String }, +} diff --git a/crates/geocoder/src/geocoder.rs b/crates/geocoder/src/geocoder.rs @@ -0,0 +1,159 @@ +use crate::error::GeocoderError; +use crate::model::{ + GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, +}; +use rusqlite::{Connection, MAIN_DB, named_params}; +use std::path::Path; + +pub struct Geocoder { + conn: Connection, +} + +impl Geocoder { + pub fn open_path<P: AsRef<Path>>(path: P) -> Result<Self, GeocoderError> { + let conn = Connection::open(path)?; + Ok(Self { conn }) + } + + pub fn open_bytes(bytes: &[u8]) -> Result<Self, GeocoderError> { + let mut conn = Connection::open_in_memory()?; + conn.deserialize_read_exact(MAIN_DB, bytes, bytes.len(), true)?; + Ok(Self { conn }) + } + + pub fn reverse( + &self, + point: GeocoderPoint, + options: Option<GeocoderReverseOptions>, + ) -> Result<Vec<GeocoderReverseResult>, GeocoderError> { + let options = options.unwrap_or_default(); + let lng_weight = point.lat.to_radians().cos().powi(2); + let mut stmt = self.conn.prepare( + r#" + SELECT + g.id, + g.name, + g.admin1_id, + g.admin1_name, + g.country_id, + g.country_name, + g.latitude, + g.longitude + FROM geonames AS g + JOIN coordinates AS c + ON g.id = c.feature_id + WHERE c.latitude BETWEEN :lat - :degree_offset AND :lat + :degree_offset + AND c.longitude BETWEEN :lng - :degree_offset AND :lng + :degree_offset + ORDER BY + ((:lat - c.latitude) * (:lat - c.latitude)) + + ((:lng - c.longitude) * (:lng - c.longitude) * :lng_weight) ASC + LIMIT :limit + "#, + )?; + let rows = stmt.query_map( + named_params! { + ":lat": point.lat, + ":lng": point.lng, + ":degree_offset": options.degree_offset, + ":lng_weight": lng_weight, + ":limit": options.limit as i64, + }, + map_reverse_row, + )?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(GeocoderError::from) + } + + pub fn country(&self, country_id: &str) -> Result<Vec<GeocoderReverseResult>, GeocoderError> { + let mut stmt = self.conn.prepare( + r#" + SELECT + id, + name, + admin1_id, + admin1_name, + country_id, + country_name, + latitude, + longitude + FROM geonames + WHERE country_id = :country_id + ORDER BY id ASC + "#, + )?; + let rows = stmt.query_map( + named_params! { + ":country_id": country_id, + }, + map_reverse_row, + )?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(GeocoderError::from) + } + + pub fn country_list(&self) -> Result<Vec<GeocoderCountryListResult>, GeocoderError> { + let mut stmt = self.conn.prepare( + r#" + SELECT + country_id, + country_name, + AVG(latitude) AS latitude_c, + AVG(longitude) AS longitude_c + FROM geonames + GROUP BY country_id, country_name + ORDER BY country_id ASC + "#, + )?; + let rows = stmt.query_map([], |row| { + Ok(GeocoderCountryListResult { + country_id: row.get("country_id")?, + country: row.get("country_name")?, + lat: row.get("latitude_c")?, + lng: row.get("longitude_c")?, + }) + })?; + rows.collect::<Result<Vec<_>, _>>() + .map_err(GeocoderError::from) + } + + pub fn country_center(&self, country_id: &str) -> Result<GeocoderPoint, GeocoderError> { + let mut stmt = self.conn.prepare( + r#" + SELECT + AVG(latitude) AS latitude_c, + AVG(longitude) AS longitude_c + FROM geonames + WHERE country_id = :country_id + "#, + )?; + let mut rows = stmt.query(named_params! { + ":country_id": country_id, + })?; + let Some(row) = rows.next()? else { + return Err(GeocoderError::CountryCenterNotFound { + country_id: country_id.to_owned(), + }); + }; + let lat: Option<f64> = row.get("latitude_c")?; + let lng: Option<f64> = row.get("longitude_c")?; + match (lat, lng) { + (Some(lat), Some(lng)) => Ok(GeocoderPoint { lat, lng }), + _ => Err(GeocoderError::CountryCenterNotFound { + country_id: country_id.to_owned(), + }), + } + } +} + +fn map_reverse_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<GeocoderReverseResult> { + Ok(GeocoderReverseResult { + id: row.get("id")?, + name: row.get("name")?, + admin1_id: row.get("admin1_id")?, + admin1_name: row.get("admin1_name")?, + country_id: row.get("country_id")?, + country_name: row.get("country_name")?, + latitude: row.get("latitude")?, + longitude: row.get("longitude")?, + }) +} diff --git a/crates/geocoder/src/lib.rs b/crates/geocoder/src/lib.rs @@ -0,0 +1,11 @@ +#![forbid(unsafe_code)] + +mod error; +mod geocoder; +mod model; + +pub use error::GeocoderError; +pub use geocoder::Geocoder; +pub use model::{ + GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, GeocoderReverseResult, +}; diff --git a/crates/geocoder/src/model.rs b/crates/geocoder/src/model.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeocoderPoint { + pub lat: f64, + pub lng: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeocoderReverseOptions { + pub limit: usize, + pub degree_offset: f64, +} + +impl Default for GeocoderReverseOptions { + fn default() -> Self { + Self { + limit: 1, + degree_offset: 0.5, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeocoderReverseResult { + pub id: i64, + pub name: String, + pub admin1_id: Option<i64>, + pub admin1_name: Option<String>, + pub country_id: String, + pub country_name: Option<String>, + pub latitude: f64, + pub longitude: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GeocoderCountryListResult { + pub country_id: String, + pub country: Option<String>, + pub lat: f64, + pub lng: f64, +} diff --git a/crates/geocoder/tests/geocoder.rs b/crates/geocoder/tests/geocoder.rs @@ -0,0 +1,286 @@ +use radroots_geocoder::{ + Geocoder, GeocoderCountryListResult, GeocoderPoint, GeocoderReverseOptions, +}; +use rusqlite::Connection; +use std::fs; +use tempfile::NamedTempFile; + +#[test] +fn reverse_returns_nearest_match_by_default() { + let geocoder = open_fixture_geocoder(); + + let results = geocoder + .reverse( + GeocoderPoint { + lat: 37.7749, + lng: -122.4194, + }, + None, + ) + .expect("reverse query"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, 1); + assert_eq!(results[0].name, "San Francisco"); + assert_eq!(results[0].country_id, "US"); + assert_eq!(results[0].admin1_id, Some(6)); + assert_eq!(results[0].admin1_name.as_deref(), Some("California")); +} + +#[test] +fn reverse_respects_limit_and_returns_sorted_matches() { + let geocoder = open_fixture_geocoder(); + + let results = geocoder + .reverse( + GeocoderPoint { + lat: 37.7749, + lng: -122.4194, + }, + Some(GeocoderReverseOptions { + limit: 2, + degree_offset: 10.0, + }), + ) + .expect("reverse query"); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].id, 1); + assert_eq!(results[1].id, 2); +} + +#[test] +fn reverse_orders_high_latitude_results_by_scaled_longitude_distance() { + let geocoder = open_high_latitude_geocoder(); + + let results = geocoder + .reverse( + GeocoderPoint { + lat: 75.0, + lng: 0.0, + }, + Some(GeocoderReverseOptions { + limit: 2, + degree_offset: 1.0, + }), + ) + .expect("reverse query"); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].id, 1); + assert_eq!(results[0].name, "Polar East"); + assert_eq!(results[1].id, 2); + assert_eq!(results[1].name, "Polar North"); +} + +#[test] +fn open_bytes_supports_reverse_queries() { + let path = build_fixture_database(); + let bytes = fs::read(&path).expect("read fixture database bytes"); + let geocoder = Geocoder::open_bytes(&bytes).expect("open byte-backed geocoder"); + + let results = geocoder + .reverse( + GeocoderPoint { + lat: 34.0522, + lng: -118.2437, + }, + None, + ) + .expect("reverse query"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, 2); + assert_eq!(results[0].name, "Los Angeles"); +} + +#[test] +fn country_returns_all_rows_for_requested_country() { + let geocoder = open_fixture_geocoder(); + + let results = geocoder.country("US").expect("country query"); + + assert_eq!(results.len(), 3); + assert!(results.iter().all(|result| result.country_id == "US")); +} + +#[test] +fn country_list_returns_average_centers() { + let geocoder = open_fixture_geocoder(); + + let results = geocoder.country_list().expect("country list query"); + + assert_eq!(results.len(), 2); + assert_eq!( + results[0], + GeocoderCountryListResult { + country_id: "BR".to_owned(), + country: Some("Brazil".to_owned()), + lat: -23.5505, + lng: -46.6333, + } + ); + assert_eq!(results[1].country_id, "US"); + assert_eq!(results[1].country.as_deref(), Some("United States")); + assert!(approx_eq( + results[1].lat, + (37.7749 + 34.0522 + 40.7128) / 3.0 + )); + assert!(approx_eq( + results[1].lng, + (-122.4194 + -118.2437 + -74.0060) / 3.0 + )); +} + +#[test] +fn country_center_returns_average_for_country() { + let geocoder = open_fixture_geocoder(); + + let result = geocoder.country_center("US").expect("country center query"); + + assert!(approx_eq(result.lat, (37.7749 + 34.0522 + 40.7128) / 3.0)); + assert!(approx_eq( + result.lng, + (-122.4194 + -118.2437 + -74.0060) / 3.0 + )); +} + +fn open_fixture_geocoder() -> Geocoder { + let path = build_fixture_database(); + Geocoder::open_path(&path).expect("open geocoder") +} + +fn open_high_latitude_geocoder() -> Geocoder { + let path = build_high_latitude_database(); + Geocoder::open_path(&path).expect("open geocoder") +} + +fn build_fixture_database() -> tempfile::TempPath { + let temp = NamedTempFile::new().expect("temp db"); + let path = temp.into_temp_path(); + seed_fixture_database(path.to_str().expect("utf-8 temp path")); + path +} + +fn build_high_latitude_database() -> tempfile::TempPath { + let temp = NamedTempFile::new().expect("temp db"); + let path = temp.into_temp_path(); + seed_high_latitude_database(path.to_str().expect("utf-8 temp path")); + path +} + +fn seed_fixture_database(path: &str) { + let conn = Connection::open(path).expect("open fixture database"); + seed_schema(&conn); + + insert_country(&conn, "US", "United States"); + insert_country(&conn, "BR", "Brazil"); + + insert_admin1(&conn, "US", 6, "California"); + insert_admin1(&conn, "US", 36, "New York"); + insert_admin1(&conn, "BR", 27, "Sao Paulo"); + + insert_feature(&conn, 1, "San Francisco", "US", 6, 37.7749, -122.4194); + insert_feature(&conn, 2, "Los Angeles", "US", 6, 34.0522, -118.2437); + insert_feature(&conn, 3, "New York City", "US", 36, 40.7128, -74.0060); + insert_feature(&conn, 4, "Sao Paulo", "BR", 27, -23.5505, -46.6333); +} + +fn seed_high_latitude_database(path: &str) { + let conn = Connection::open(path).expect("open fixture database"); + seed_schema(&conn); + + insert_country(&conn, "NO", "Norway"); + insert_admin1(&conn, "NO", 1, "Nord"); + + insert_feature(&conn, 1, "Polar East", "NO", 1, 75.02, 0.10); + insert_feature(&conn, 2, "Polar North", "NO", 1, 75.05, 0.05); +} + +fn seed_schema(conn: &Connection) { + conn.execute_batch( + r#" + CREATE TABLE countries( + id TEXT, + name TEXT, + PRIMARY KEY (id) + ); + CREATE TABLE admin1( + country_id TEXT, + id INTEGER, + name TEXT, + PRIMARY KEY (country_id, id) + ); + CREATE TABLE features( + id INTEGER, + name TEXT, + country_id TEXT, + admin1_id INTEGER, + PRIMARY KEY (id) + ); + CREATE TABLE coordinates( + feature_id INTEGER, + latitude REAL, + longitude REAL, + PRIMARY KEY (feature_id) + ); + CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude); + CREATE VIEW geonames AS + SELECT + features.id, + features.name, + admin1.id AS admin1_id, + admin1.name AS admin1_name, + countries.id AS country_id, + countries.name AS country_name, + coordinates.latitude AS latitude, + coordinates.longitude AS longitude + FROM features + LEFT JOIN countries ON features.country_id = countries.id + LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id + JOIN coordinates ON features.id = coordinates.feature_id; + "#, + ) + .expect("create fixture schema"); +} + +fn insert_country(conn: &Connection, id: &str, name: &str) { + conn.execute( + "INSERT INTO countries (id, name) VALUES (?1, ?2)", + (id, name), + ) + .expect("insert country"); +} + +fn insert_admin1(conn: &Connection, country_id: &str, id: i64, name: &str) { + conn.execute( + "INSERT INTO admin1 (country_id, id, name) VALUES (?1, ?2, ?3)", + (country_id, id, name), + ) + .expect("insert admin1"); +} + +fn insert_feature( + conn: &Connection, + id: i64, + name: &str, + country_id: &str, + admin1_id: i64, + latitude: f64, + longitude: f64, +) { + conn.execute( + "INSERT INTO features (id, name, country_id, admin1_id) VALUES (?1, ?2, ?3, ?4)", + (id, name, country_id, admin1_id), + ) + .expect("insert feature"); + conn.execute( + "INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (?1, ?2, ?3)", + (id, latitude, longitude), + ) + .expect("insert coordinate"); +} + +fn approx_eq(left: f64, right: f64) -> bool { + (left - right).abs() < 0.000_001 +}