use std::error::Error;
use polars::prelude::*;
use rstar::{PointDistance, RTree, RTreeObject, AABB};
use crate::cli::{DistUnit, NearestArgs};
use crate::config::{resolve, Settings};
use crate::geo::{chord2_to_m, unit_sphere};
use crate::pipeline::{run_module, Enricher, OutputKind, OutputSpec, Value};
struct RefPoint {
xyz: [f64; 3],
tag: usize,
}
impl RTreeObject for RefPoint {
type Envelope = AABB<[f64; 3]>;
fn envelope(&self) -> Self::Envelope {
AABB::from_point(self.xyz)
}
}
impl PointDistance for RefPoint {
fn distance_2(&self, p: &[f64; 3]) -> f64 {
let (dx, dy, dz) = (self.xyz[0] - p[0], self.xyz[1] - p[1], self.xyz[2] - p[2]);
dx * dx + dy * dy + dz * dz
}
}
pub struct NearestEnricher {
tree: RTree<RefPoint>,
names: Vec<Option<String>>,
to_km: bool,
name_column: String,
dist_column: String,
}
impl NearestEnricher {
pub fn from_points<I>(points: I, unit: DistUnit, name_column: String, dist_column: String) -> Self
where
I: IntoIterator<Item = (f64, f64, Option<String>)>,
{
let mut refs = Vec::new();
let mut names = Vec::new();
for (lon, lat, name) in points {
if !lon.is_finite() || !lat.is_finite() {
continue;
}
refs.push(RefPoint { xyz: unit_sphere(lon, lat), tag: names.len() });
names.push(name);
}
NearestEnricher {
tree: RTree::bulk_load(refs),
names,
to_km: matches!(unit, DistUnit::Km),
name_column,
dist_column,
}
}
pub fn open(
df: &DataFrame,
lon_col: &str,
lat_col: &str,
name_field: &str,
unit: DistUnit,
name_column: String,
dist_column: String,
) -> Result<Self, Box<dyn Error>> {
let lon = column_f64(df, lon_col)?;
let lat = column_f64(df, lat_col)?;
let names = column_text(df, name_field)?;
let points = (0..df.height()).map(|i| (lon[i], lat[i], names[i].clone()));
let enr = Self::from_points(points, unit, name_column, dist_column);
if enr.names.is_empty() {
eprintln!(
"[geoenrich] warning: reference table '{}' has no usable location; \
nearest name/distance will be null",
name_field
);
}
Ok(enr)
}
}
impl Enricher for NearestEnricher {
fn outputs(&self) -> Vec<OutputSpec> {
Vec::from([
OutputSpec { name: self.name_column.clone(), kind: OutputKind::Text },
OutputSpec { name: self.dist_column.clone(), kind: OutputKind::Float },
])
}
fn enrich(&self, lon: f64, lat: f64) -> Vec<Value> {
let p = unit_sphere(lon, lat);
match self.tree.nearest_neighbor(p) {
Some(rp) => {
let meters = chord2_to_m(rp.distance_2(&p));
let dist = if self.to_km { meters / 1000.0 } else { meters };
Vec::from([Value::Text(self.names[rp.tag].clone()), Value::Float(dist)])
}
None => Vec::from([Value::Text(None), Value::Float(f64::NAN)]),
}
}
}
fn column_f64(df: &DataFrame, name: &str) -> Result<Vec<f64>, Box<dyn Error>> {
let s = df
.column(name)
.map_err(|_| format!("reference table has no column '{name}'"))?;
let ca = s.cast(&DataType::Float64)?;
Ok(ca.f64()?.into_iter().map(|o| o.unwrap_or(f64::NAN)).collect())
}
fn column_text(df: &DataFrame, name: &str) -> Result<Vec<Option<String>>, Box<dyn Error>> {
let s = df
.column(name)
.map_err(|_| format!("reference table has no name column '{name}'"))?;
let ca = s.cast(&DataType::String)?;
Ok(ca.str()?.into_iter().map(|o| o.map(|v| v.to_string())).collect())
}
pub fn run(args: NearestArgs) -> Result<(), Box<dyn Error>> {
let s: Settings = resolve(&args.common, None)?;
let df = crate::io::read_frame(&args.common.input, args.common.in_format)?;
let reference = crate::io::read_frame(&args.to, args.to_format)?;
let out_path = args
.common
.output
.clone()
.unwrap_or_else(|| super::default_output(&args.common.input, "nearest", args.common.in_format));
let enr = NearestEnricher::open(
&reference,
&args.to_lon_col,
&args.to_lat_col,
&args.name_field,
args.unit,
args.name_column,
args.dist_column,
)?;
run_module(&enr, df, &s, &out_path, args.common.out_format)
}