use oxigdal_analytics::clustering::DbscanClusterer;
use oxigdal_analytics::clustering::{
OpticsClusterer, OpticsOptions, Point2D, extract_dbscan_clusters, extract_xi_clusters, optics,
};
use scirs2_core::ndarray::array;
fn grid_block(cx: f64, cy: f64, side: usize, step: f64) -> Vec<Point2D> {
let half = (side as f64 - 1.0) * 0.5 * step;
let mut out = Vec::with_capacity(side * side);
for j in 0..side {
for i in 0..side {
let x = cx - half + (i as f64) * step;
let y = cy - half + (j as f64) * step;
out.push(Point2D::new(x, y));
}
}
out
}
#[test]
fn test_optics_constant_density_cluster_single_block() {
let pts = grid_block(0.0, 0.0, 5, 0.1);
let opts = OpticsOptions {
min_samples: 3,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
assert_eq!(res.ordering.len(), pts.len());
assert_eq!(res.reachability.len(), pts.len());
assert_eq!(res.core_distances.len(), pts.len());
let undefined_count = res.reachability.iter().filter(|r| !r.is_finite()).count();
assert_eq!(
undefined_count, 1,
"exactly one point (the seed) should have UNDEFINED reachability"
);
for r in res.reachability.iter().filter(|r| r.is_finite()) {
assert!(*r <= 0.5, "reachability {r} out of bounds for dense block");
}
}
#[test]
fn test_optics_two_density_levels_single_pass_extracts_both() {
let mut pts = grid_block(0.0, 0.0, 5, 0.05);
pts.extend(grid_block(5.0, 0.0, 5, 0.2));
let opts = OpticsOptions {
min_samples: 3,
max_eps: 1.0,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
let dbscan = extract_dbscan_clusters(&res, 0.6);
assert!(
dbscan.len() >= 2,
"expected ≥2 DBSCAN-compat clusters across two density levels, got {}",
dbscan.len()
);
}
#[test]
fn test_optics_reachability_plot_monotonic_within_cluster() {
let mut pts = Vec::new();
for i in 0..10 {
pts.push(Point2D::new(i as f64 * 0.1, 0.0));
}
let opts = OpticsOptions {
min_samples: 2,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
let mut max_finite: f64 = 0.0;
for r in res.reachability.iter().filter(|r| r.is_finite()) {
if *r > max_finite {
max_finite = *r;
}
}
assert!(
max_finite < 0.3,
"reachability plateau too tall ({max_finite}) for a uniform line"
);
}
#[test]
fn test_optics_core_distance_undefined_for_isolated_point() {
let mut pts = grid_block(0.0, 0.0, 4, 0.1);
pts.push(Point2D::new(100.0, 100.0));
let opts = OpticsOptions {
min_samples: 3,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
let isolated_idx = pts.len() - 1;
let pos = res
.ordering
.iter()
.position(|&i| i == isolated_idx)
.expect("isolated point must be in the ordering");
assert!(
!res.core_distances[pos].is_finite(),
"isolated point should have UNDEFINED core distance"
);
}
#[test]
fn test_optics_max_eps_caps_reachability() {
let mut pts = grid_block(0.0, 0.0, 3, 0.1);
pts.push(Point2D::new(10.0, 10.0)); let tight = OpticsOptions {
min_samples: 2,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &tight).expect("optics should run");
let isolated_idx = pts.len() - 1;
let pos = res
.ordering
.iter()
.position(|&i| i == isolated_idx)
.expect("isolated point must appear in ordering");
assert!(
!res.reachability[pos].is_finite(),
"far point should have UNDEFINED reachability under tight max_eps"
);
}
#[test]
fn test_optics_min_samples_filters_tiny_clusters() {
let pts = vec![
Point2D::new(0.0, 0.0),
Point2D::new(0.1, 0.0),
Point2D::new(0.2, 0.0),
];
let opts = OpticsOptions {
min_samples: 10,
max_eps: 1.0,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
for c in &res.core_distances {
assert!(
!c.is_finite(),
"no point should be a core point with min_samples=10"
);
}
}
#[test]
fn test_optics_ordering_starts_from_unprocessed() {
let mut pts = grid_block(0.0, 0.0, 3, 0.1);
pts.extend(grid_block(10.0, 10.0, 3, 0.1));
let opts = OpticsOptions {
min_samples: 2,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
assert_eq!(res.ordering.len(), pts.len());
let mut sorted = res.ordering.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(
sorted.len(),
pts.len(),
"ordering must contain each point exactly once"
);
for &i in &res.ordering {
assert!(i < pts.len());
}
}
#[test]
fn test_xi_extraction_marks_steep_down_steep_up_pair() {
let mut pts = Vec::new();
pts.extend_from_slice(&grid_block(0.0, 0.0, 4, 0.1)); pts.push(Point2D::new(50.0, 50.0));
pts.extend_from_slice(&grid_block(100.0, 100.0, 4, 0.1)); let opts = OpticsOptions {
min_samples: 3,
max_eps: 1.0,
xi: 0.10,
};
let res = optics(&pts, &opts).expect("optics should run");
let clusters = extract_xi_clusters(&res, 0.10);
for c in &clusters {
assert!(c.start <= c.end);
assert!(c.end < res.reachability.len());
}
}
#[test]
fn test_xi_extraction_no_steep_areas_returns_empty() {
let pts: Vec<Point2D> = (0..20).map(|i| Point2D::new(i as f64 * 0.1, 0.0)).collect();
let opts = OpticsOptions {
min_samples: 2,
max_eps: 0.5,
xi: 0.5,
};
let res = optics(&pts, &opts).expect("optics should run");
let clusters = extract_xi_clusters(&res, 0.5);
assert!(
clusters.is_empty(),
"flat reachability plot should produce no ξ-clusters at ξ=0.5"
);
}
#[test]
fn test_xi_extraction_default_0p05_recovers_two_density_levels() {
let mut pts = grid_block(0.0, 0.0, 5, 0.05);
pts.extend(grid_block(5.0, 0.0, 5, 0.2));
let opts = OpticsOptions::default();
let res = optics(&pts, &opts).expect("optics should run");
let clusters = extract_xi_clusters(&res, 0.05);
for c in &clusters {
assert!(c.start <= c.end);
assert!(c.end < res.reachability.len());
}
for w in clusters.windows(2) {
assert!(w[0].start <= w[1].start);
}
}
#[test]
fn test_dbscan_compat_extraction_matches_dbscan_clusterer_eps_0p5() {
let mut pts = Vec::new();
pts.extend(grid_block(0.0, 0.0, 3, 0.1));
pts.extend(grid_block(5.0, 5.0, 3, 0.1));
pts.push(Point2D::new(2.5, 2.5));
let opts = OpticsOptions {
min_samples: 2,
max_eps: 1.0,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
let optics_clusters = extract_dbscan_clusters(&res, 0.5);
let mut data = Vec::with_capacity(pts.len() * 2);
for p in &pts {
data.push(p.x);
data.push(p.y);
}
let arr = scirs2_core::ndarray::Array2::from_shape_vec((pts.len(), 2), data)
.expect("Array2 from_shape_vec should succeed");
let dbscan = DbscanClusterer::new(0.5, 2);
let dbscan_res = dbscan.fit(&arr.view()).expect("DBSCAN should run");
assert_eq!(
optics_clusters.len(),
dbscan_res.n_clusters,
"OPTICS DBSCAN-compat extraction should yield same cluster count as DBSCAN"
);
}
#[test]
fn test_dbscan_compat_extraction_eps_zero_returns_empty() {
let pts = grid_block(0.0, 0.0, 4, 0.1);
let opts = OpticsOptions {
min_samples: 2,
max_eps: 0.5,
xi: 0.05,
};
let res = optics(&pts, &opts).expect("optics should run");
let clusters = extract_dbscan_clusters(&res, 0.0);
assert!(clusters.is_empty(), "eps=0 must yield no clusters");
}
#[test]
fn test_optics_empty_input_returns_empty_result() {
let opts = OpticsOptions::default();
let res = optics(&[], &opts).expect("empty input should be valid");
assert!(res.ordering.is_empty());
assert!(res.reachability.is_empty());
assert!(res.core_distances.is_empty());
assert!(res.clusters.is_empty());
let cl = OpticsClusterer::new(opts);
let res2 = cl.fit(&[]).expect("clusterer empty input ok");
assert!(res2.ordering.is_empty());
}
#[test]
fn test_optics_single_point_returns_one_unprocessed() {
let pts = vec![Point2D::new(42.0, 13.0)];
let opts = OpticsOptions::default();
let res = optics(&pts, &opts).expect("single point should be valid");
assert_eq!(res.ordering.len(), 1);
assert_eq!(res.ordering[0], 0);
assert!(
!res.reachability[0].is_finite(),
"single-point reachability must be UNDEFINED"
);
assert!(
!res.core_distances[0].is_finite(),
"single point is never a core point under default min_samples=5"
);
}
#[test]
fn test_optics_options_default_min_samples_5_xi_0p05() {
let opts = OpticsOptions::default();
assert_eq!(opts.min_samples, 5);
assert!((opts.xi - 0.05).abs() < 1e-12);
assert!(opts.max_eps.is_infinite());
let cl = OpticsClusterer::new(opts);
assert_eq!(cl.options().min_samples, 5);
assert!((cl.options().xi - 0.05).abs() < 1e-12);
assert!(cl.options().max_eps.is_infinite());
}
#[test]
fn smoke_ndarray_independence() {
let _ = array![1.0_f64];
let pts = grid_block(0.0, 0.0, 2, 0.1);
let res = optics(&pts, &OpticsOptions::default()).expect("smoke");
assert_eq!(res.ordering.len(), pts.len());
}