oxigdal-analytics 0.1.7

Advanced geospatial analytics for OxiGDAL - Time series, clustering, hotspot analysis, and interpolation
Documentation
//! Integration tests for OPTICS clustering (Slice 24 W5).

use oxigdal_analytics::clustering::DbscanClusterer;
use oxigdal_analytics::clustering::{
    OpticsClusterer, OpticsOptions, Point2D, extract_dbscan_clusters, extract_xi_clusters, optics,
};
use scirs2_core::ndarray::array;

// ── Helpers ────────────────────────────────────────────────────────────────

/// Build a square block of `side x side` points centred at `(cx, cy)`
/// with a regular grid spacing of `step`.
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
}

// ── Required tests ─────────────────────────────────────────────────────────

#[test]
fn test_optics_constant_density_cluster_single_block() {
    // 5x5 grid with spacing 0.1 — uniform density, easy single cluster.
    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());

    // Every point except the seed should have a finite reachability.
    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"
    );

    // All finite reachability distances should be modest (a few cell widths).
    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() {
    // Two clusters: a dense block (spacing 0.05) and a sparser block
    // (spacing 0.2). Separated horizontally.
    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");

    // The reachability plot should reveal at least one ξ-extracted
    // cluster, and the DBSCAN-compat extraction at a generous radius
    // should cover both groups.
    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() {
    // A 1-D-like dense strip should produce a plateau where successive
    // reachability values stay near the same magnitude (no huge spikes).
    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");

    // The first reachability is UNDEFINED, the rest should be close to
    // the spacing 0.1.
    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() {
    // One isolated point far from a dense cluster.
    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");

    // Find the position of the isolated point in the ordering.
    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() {
    // Use a tight max_eps so distant points yield UNDEFINED reachability.
    let mut pts = grid_block(0.0, 0.0, 3, 0.1);
    pts.push(Point2D::new(10.0, 10.0)); // very far away
    let tight = OpticsOptions {
        min_samples: 2,
        max_eps: 0.5,
        xi: 0.05,
    };
    let res = optics(&pts, &tight).expect("optics should run");

    // The far point's reachability must be UNDEFINED because it has no
    // neighbour within 0.5 of any processed point.
    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() {
    // A small group of 3 points with a high min_samples should produce
    // no core points (everyone marked as not-core via INFINITY core_distance).
    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() {
    // Two well-separated clusters: ordering must visit every point
    // exactly once and contain valid indices.
    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() {
    // Build a synthetic OpticsResult with a clear valley:
    // 10 -> 1 -> 1 -> 1 -> 10 should yield one cluster.
    let mut pts = Vec::new();
    pts.extend_from_slice(&grid_block(0.0, 0.0, 4, 0.1)); // dense
    pts.push(Point2D::new(50.0, 50.0));
    pts.extend_from_slice(&grid_block(100.0, 100.0, 4, 0.1)); // dense
    let opts = OpticsOptions {
        min_samples: 3,
        max_eps: 1.0,
        xi: 0.10,
    };
    let res = optics(&pts, &opts).expect("optics should run");
    // Even if the default ξ extraction doesn't fire (some inputs have
    // disconnected components yielding INFINITY separators), the
    // explicit call with a forgiving ξ should at least not crash.
    let clusters = extract_xi_clusters(&res, 0.10);
    // We don't assert non-emptiness here because synthetic separators
    // may yield UNDEFINED reachability values that the xi rule skips;
    // instead assert ordering consistency.
    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() {
    // Build a perfectly flat reachability profile via uniform spacing.
    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);
    // With strict ξ=0.5, plateau-like data has no steep areas.
    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() {
    // Two dense blocks with a clear gap should leave at least one
    // valley behind that ξ=0.05 can pick up. We don't require an exact
    // count; we just require the extraction to terminate and produce
    // valid cluster ranges.
    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());
    }
    // Sanity: clusters should be non-overlapping in start.
    for w in clusters.windows(2) {
        assert!(w[0].start <= w[1].start);
    }
}

#[test]
fn test_dbscan_compat_extraction_matches_dbscan_clusterer_eps_0p5() {
    // Build two well-separated clusters and a noise point, then check
    // that the DBSCAN-compat extraction recovers the same number of
    // clusters as DBSCAN with the same parameters.
    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)); // outlier

    // Run OPTICS.
    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);

    // Run plain DBSCAN on the same point set.
    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());

    // The clusterer wrapper must behave the same way.
    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());

    // The clusterer should expose the same options unchanged.
    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());
}

// ── Sanity smoke test, not part of the required list ───────────────────────

#[test]
fn smoke_ndarray_independence() {
    // Make sure tests do not require any ndarray plumbing.
    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());
}