use crate::error::{AnalyticsError, Result};
use rstar::{AABB, PointDistance, RTree, RTreeObject};
use std::cmp::Ordering;
use std::collections::BinaryHeap;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point2D {
pub x: f64,
pub y: f64,
}
impl Point2D {
#[must_use]
#[inline]
pub const fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
#[derive(Debug, Clone, Copy)]
pub struct OpticsOptions {
pub min_samples: usize,
pub max_eps: f64,
pub xi: f64,
}
impl Default for OpticsOptions {
fn default() -> Self {
Self {
min_samples: 5,
max_eps: f64::INFINITY,
xi: 0.05,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OpticsCluster {
pub start: usize,
pub end: usize,
pub xi_steepness: f64,
}
#[derive(Debug, Clone)]
pub struct OpticsResult {
pub ordering: Vec<usize>,
pub reachability: Vec<f64>,
pub core_distances: Vec<f64>,
pub clusters: Vec<OpticsCluster>,
}
#[derive(Debug, Clone, Copy)]
pub struct OpticsClusterer {
options: OpticsOptions,
}
impl OpticsClusterer {
#[must_use]
pub fn new(options: OpticsOptions) -> Self {
Self { options }
}
pub fn fit(&self, points: &[Point2D]) -> Result<OpticsResult> {
optics(points, &self.options)
}
#[must_use]
pub fn options(&self) -> &OpticsOptions {
&self.options
}
}
#[derive(Debug, Clone, Copy)]
struct IndexedPoint {
index: usize,
x: f64,
y: f64,
}
impl RTreeObject for IndexedPoint {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
AABB::from_point([self.x, self.y])
}
}
impl PointDistance for IndexedPoint {
fn distance_2(&self, p: &[f64; 2]) -> f64 {
let dx = self.x - p[0];
let dy = self.y - p[1];
dx * dx + dy * dy
}
}
#[derive(Debug, Clone, Copy)]
struct HeapEntry {
reachability: f64,
index: usize,
}
impl PartialEq for HeapEntry {
fn eq(&self, other: &Self) -> bool {
self.reachability.total_cmp(&other.reachability) == Ordering::Equal
&& self.index == other.index
}
}
impl Eq for HeapEntry {}
impl PartialOrd for HeapEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapEntry {
fn cmp(&self, other: &Self) -> Ordering {
other
.reachability
.total_cmp(&self.reachability)
.then_with(|| other.index.cmp(&self.index))
}
}
pub fn optics(points: &[Point2D], options: &OpticsOptions) -> Result<OpticsResult> {
if options.min_samples == 0 {
return Err(AnalyticsError::invalid_parameter(
"min_samples",
"must be positive",
));
}
if options.max_eps <= 0.0 || options.max_eps.is_nan() {
return Err(AnalyticsError::invalid_parameter(
"max_eps",
"must be positive (or f64::INFINITY)",
));
}
if !(0.0..1.0).contains(&options.xi) {
return Err(AnalyticsError::invalid_parameter(
"xi",
"must lie in [0, 1)",
));
}
let n = points.len();
if n == 0 {
return Ok(OpticsResult {
ordering: Vec::new(),
reachability: Vec::new(),
core_distances: Vec::new(),
clusters: Vec::new(),
});
}
let indexed: Vec<IndexedPoint> = points
.iter()
.enumerate()
.map(|(i, p)| IndexedPoint {
index: i,
x: p.x,
y: p.y,
})
.collect();
let tree = RTree::bulk_load(indexed);
let mut processed: Vec<bool> = vec![false; n];
let mut reach_by_idx: Vec<f64> = vec![f64::INFINITY; n];
let mut core_by_idx: Vec<f64> = vec![f64::INFINITY; n];
let mut ordering: Vec<usize> = Vec::with_capacity(n);
let mut reachability: Vec<f64> = Vec::with_capacity(n);
let mut core_distances: Vec<f64> = Vec::with_capacity(n);
for start in 0..n {
if processed[start] {
continue;
}
let seed_neighbours = neighbours_within(&tree, points[start], options.max_eps);
let seed_core = core_distance(&seed_neighbours, options.min_samples);
core_by_idx[start] = seed_core;
processed[start] = true;
ordering.push(start);
reachability.push(reach_by_idx[start]);
core_distances.push(seed_core);
if !seed_core.is_finite() {
continue;
}
let mut queue: BinaryHeap<HeapEntry> = BinaryHeap::new();
update_seeds(
&seed_neighbours,
start,
seed_core,
&mut reach_by_idx,
&processed,
&mut queue,
);
while let Some(HeapEntry {
index: q,
reachability: r_q,
}) = queue.pop()
{
if processed[q] {
continue;
}
if (reach_by_idx[q] - r_q).abs() > 0.0 && reach_by_idx[q] < r_q {
continue;
}
let q_neighbours = neighbours_within(&tree, points[q], options.max_eps);
let q_core = core_distance(&q_neighbours, options.min_samples);
core_by_idx[q] = q_core;
processed[q] = true;
ordering.push(q);
reachability.push(reach_by_idx[q]);
core_distances.push(q_core);
if q_core.is_finite() {
update_seeds(
&q_neighbours,
q,
q_core,
&mut reach_by_idx,
&processed,
&mut queue,
);
}
}
}
let mut result = OpticsResult {
ordering,
reachability,
core_distances,
clusters: Vec::new(),
};
result.clusters = extract_xi_clusters(&result, options.xi);
Ok(result)
}
fn update_seeds(
neighbours: &[NeighbourInfo],
centre: usize,
centre_core: f64,
reach_by_idx: &mut [f64],
processed: &[bool],
queue: &mut BinaryHeap<HeapEntry>,
) {
for nb in neighbours {
if nb.index == centre || processed[nb.index] {
continue;
}
let new_reach = centre_core.max(nb.distance);
if new_reach < reach_by_idx[nb.index] {
reach_by_idx[nb.index] = new_reach;
queue.push(HeapEntry {
reachability: new_reach,
index: nb.index,
});
}
}
}
#[derive(Debug, Clone, Copy)]
struct NeighbourInfo {
index: usize,
distance: f64,
}
fn neighbours_within(
tree: &RTree<IndexedPoint>,
point: Point2D,
max_eps: f64,
) -> Vec<NeighbourInfo> {
let mut out: Vec<NeighbourInfo> = Vec::new();
let query = [point.x, point.y];
if max_eps.is_finite() {
let radius_sq = max_eps * max_eps;
for candidate in tree.locate_within_distance(query, radius_sq) {
let dx = candidate.x - query[0];
let dy = candidate.y - query[1];
let dist = (dx * dx + dy * dy).sqrt();
if dist <= max_eps {
out.push(NeighbourInfo {
index: candidate.index,
distance: dist,
});
}
}
} else {
for candidate in tree.nearest_neighbor_iter(query) {
let dx = candidate.x - query[0];
let dy = candidate.y - query[1];
let dist = (dx * dx + dy * dy).sqrt();
out.push(NeighbourInfo {
index: candidate.index,
distance: dist,
});
}
}
out
}
fn core_distance(neighbours: &[NeighbourInfo], min_samples: usize) -> f64 {
if neighbours.len() < min_samples {
return f64::INFINITY;
}
let mut sorted: Vec<f64> = neighbours.iter().map(|n| n.distance).collect();
sorted.sort_by(|a, b| a.total_cmp(b));
sorted[min_samples - 1]
}
#[must_use]
pub fn extract_xi_clusters(result: &OpticsResult, xi: f64) -> Vec<OpticsCluster> {
let reach = &result.reachability;
let n = reach.len();
if n < 3 || !(0.0..1.0).contains(&xi) {
return Vec::new();
}
let factor = 1.0 - xi;
let mut downs: Vec<(usize, usize)> = Vec::new();
let mut ups: Vec<(usize, usize)> = Vec::new();
let is_steep_down =
|a: f64, b: f64| -> bool { a.is_finite() && b.is_finite() && a >= b && b <= a * factor };
let is_steep_up =
|a: f64, b: f64| -> bool { a.is_finite() && b.is_finite() && a <= b && a <= b * factor };
let mut i = 0;
while i + 1 < n {
let r0 = reach[i];
let r1 = reach[i + 1];
if is_steep_down(r0, r1) {
let s = i;
let mut j = i + 1;
while j + 1 < n {
let a = reach[j];
let b = reach[j + 1];
if !is_steep_down(a, b) {
break;
}
j += 1;
}
downs.push((s, j));
i = j + 1;
} else if is_steep_up(r0, r1) {
let s = i;
let mut j = i + 1;
while j + 1 < n {
let a = reach[j];
let b = reach[j + 1];
if !is_steep_up(a, b) {
break;
}
j += 1;
}
ups.push((s, j));
i = j + 1;
} else {
i += 1;
}
}
let mut clusters: Vec<OpticsCluster> = Vec::new();
for &(d_start, d_end) in &downs {
if let Some(&(_u_start, u_end)) = ups.iter().find(|&&(u_s, _)| u_s >= d_end) {
let cluster_start = d_start;
let cluster_end = u_end;
let span = cluster_end.saturating_sub(cluster_start).saturating_add(1);
if span < 3 {
continue;
}
let r_start = reach[cluster_start];
let r_end = reach[cluster_end];
let steepness = if r_start.is_finite() && r_end.is_finite() {
let max_r = r_start.max(r_end).max(f64::EPSILON);
(r_start - r_end).abs() / max_r
} else {
xi
};
clusters.push(OpticsCluster {
start: cluster_start,
end: cluster_end,
xi_steepness: steepness,
});
}
}
clusters
}
#[must_use]
pub fn extract_dbscan_clusters(result: &OpticsResult, eps: f64) -> Vec<OpticsCluster> {
if !(eps > 0.0 && eps.is_finite()) {
return Vec::new();
}
let reach = &result.reachability;
let n = reach.len();
if n == 0 {
return Vec::new();
}
let mut clusters: Vec<OpticsCluster> = Vec::new();
let mut start: Option<usize> = None;
for i in 0..n {
if reach[i] <= eps {
if start.is_none() {
start = Some(i);
}
} else if let Some(s) = start.take() {
if i > s {
clusters.push(OpticsCluster {
start: s,
end: i - 1,
xi_steepness: 0.0,
});
}
}
}
if let Some(s) = start {
clusters.push(OpticsCluster {
start: s,
end: n - 1,
xi_steepness: 0.0,
});
}
clusters
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_default_values() {
let opts = OpticsOptions::default();
assert_eq!(opts.min_samples, 5);
assert!((opts.xi - 0.05).abs() < 1e-12);
assert!(opts.max_eps.is_infinite());
}
#[test]
fn empty_input_returns_empty_result() {
let res = optics(&[], &OpticsOptions::default()).expect("empty");
assert!(res.ordering.is_empty());
assert!(res.reachability.is_empty());
assert!(res.core_distances.is_empty());
assert!(res.clusters.is_empty());
}
#[test]
fn invalid_min_samples_zero_fails() {
let opts = OpticsOptions {
min_samples: 0,
..OpticsOptions::default()
};
let pts = [Point2D::new(0.0, 0.0)];
assert!(optics(&pts, &opts).is_err());
}
}