use numrs2::array::Array;
use numrs2::stats::{histogram, histogram2d, histogram_dd, histogramdd};
const EPS: f64 = 1e-9;
fn assert_vec_close(actual: &[f64], expected: &[f64], ctx: &str) {
assert_eq!(actual.len(), expected.len(), "{ctx}: length mismatch");
for (i, (&a, &e)) in actual.iter().zip(expected.iter()).enumerate() {
assert!(
(a - e).abs() < EPS,
"{ctx} at index {i}: expected {e}, got {a}"
);
}
}
#[test]
fn test_histogram_density_matches_numpy() {
let data = Array::from_vec(vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0]);
let (counts, edges) = histogram(&data, 4, None, None, None).expect("counts should succeed");
assert_vec_close(&counts.to_vec(), &[1.0, 2.0, 3.0, 1.0], "counts");
assert_vec_close(&edges.to_vec(), &[1.0, 1.75, 2.5, 3.25, 4.0], "edges");
let (density, edges_d) =
histogram(&data, 4, None, None, Some(true)).expect("density should succeed");
assert_vec_close(&edges_d.to_vec(), &edges.to_vec(), "density_edges");
let expected_density = [
0.19047619047619047,
0.38095238095238093,
0.5714285714285714,
0.19047619047619047,
];
assert_vec_close(&density.to_vec(), &expected_density, "density");
let widths: Vec<f64> = edges_d.to_vec().windows(2).map(|w| w[1] - w[0]).collect();
let integral: f64 = density
.to_vec()
.iter()
.zip(widths.iter())
.map(|(&d, &w)| d * w)
.sum();
assert!(
(integral - 1.0).abs() < EPS,
"density should integrate to 1, got {integral}"
);
}
#[test]
fn test_histogram_weighted_density_matches_numpy() {
let data = Array::from_vec(vec![1.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0]);
let w = Array::from_vec(vec![1.0, 2.0, 1.0, 3.0, 1.0, 1.0, 2.0]);
let (density, _) =
histogram(&data, 4, None, Some(&w), Some(true)).expect("weighted density should succeed");
let expected = [
0.1212121212121212,
0.36363636363636365,
0.6060606060606061,
0.2424242424242424,
];
assert_vec_close(&density.to_vec(), &expected, "weighted_density");
}
#[test]
fn test_histogram_density_with_zero_total_is_nan_like_numpy() {
let data: Array<f64> = Array::from_vec(vec![100.0, 200.0]);
let (density, _) = histogram(&data, 3, Some((0.0, 3.0)), None, Some(true))
.expect("density should not error even when total is zero");
for v in density.to_vec() {
assert!(v.is_nan(), "expected NaN, got {v}");
}
}
#[test]
fn test_histogram_density_false_or_none_equal_raw_counts() {
let data = Array::from_vec(vec![1.0, 2.0, 2.0, 3.0]);
let (none_counts, _) = histogram(&data, 3, None, None, None).expect("ok");
let (false_counts, _) = histogram(&data, 3, None, None, Some(false)).expect("ok");
assert_eq!(none_counts.to_vec(), false_counts.to_vec());
}
#[test]
fn test_histogram_bin_edges_right_open_except_last() {
let data = Array::from_vec(vec![1.0, 2.0, 1.0]);
let (counts, edges) =
histogram(&data, 3, Some((0.0, 3.0)), None, None).expect("histogram should succeed");
assert_vec_close(&edges.to_vec(), &[0.0, 1.0, 2.0, 3.0], "edges");
assert_vec_close(&counts.to_vec(), &[0.0, 2.0, 1.0], "counts");
}
#[test]
fn test_histogram_value_at_last_edge_included_in_last_bin() {
let data = Array::from_vec(vec![0.0, 1.0, 2.0, 3.0]);
let (counts, _) =
histogram(&data, 3, Some((0.0, 3.0)), None, None).expect("histogram should succeed");
assert_vec_close(&counts.to_vec(), &[1.0, 1.0, 2.0], "counts_last_edge");
}
#[test]
fn test_histogram_degenerate_range_expands_by_half() {
let data = Array::from_vec(vec![5.0, 5.0, 5.0]);
let (counts, edges) = histogram(&data, 3, None, None, None).expect("should not panic or error");
assert_vec_close(
&edges.to_vec(),
&[4.5, 4.833333333333333, 5.166666666666667, 5.5],
"degenerate_edges",
);
assert_vec_close(&counts.to_vec(), &[0.0, 3.0, 0.0], "degenerate_counts");
}
#[test]
fn test_histogram_explicit_degenerate_range_is_expanded_not_rejected() {
let data = Array::from_vec(vec![5.0, 5.0, 5.0]);
let result = histogram(&data, 2, Some((5.0, 5.0)), None, None);
assert!(
result.is_ok(),
"explicit min==max range should be expanded, not rejected"
);
}
#[test]
fn test_histogram_min_greater_than_max_is_still_rejected() {
let data = Array::from_vec(vec![1.0, 2.0, 3.0]);
assert!(histogram(&data, 2, Some((5.0, 1.0)), None, None).is_err());
}
#[test]
fn test_histogram_auto_range_nan_is_a_clean_error_not_a_panic() {
let data = Array::from_vec(vec![1.0, f64::NAN, 3.0]);
let result = histogram(&data, 3, None, None, None);
assert!(
result.is_err(),
"auto-range over NaN data should error, not panic or silently zero out"
);
}
#[test]
fn test_histogram_explicit_nan_range_bound_is_rejected() {
let data = Array::from_vec(vec![1.0, 2.0, 3.0]);
assert!(histogram(&data, 3, Some((f64::NAN, 5.0)), None, None).is_err());
assert!(histogram(&data, 3, Some((0.0, f64::NAN)), None, None).is_err());
}
#[test]
fn test_histogram_explicit_finite_range_with_nan_sample_excludes_it_not_an_error() {
let data = Array::from_vec(vec![1.0, f64::NAN, 3.0]);
let (counts, edges) =
histogram(&data, 3, Some((0.0, 5.0)), None, None).expect("finite range should succeed");
assert_vec_close(
&counts.to_vec(),
&[1.0, 1.0, 0.0],
"nan_sample_excluded_counts",
);
assert_vec_close(
&edges.to_vec(),
&[0.0, 1.6666666666666667, 3.3333333333333335, 5.0],
"nan_sample_excluded_edges",
);
}
#[test]
fn test_histogram2d_auto_range_nan_in_either_axis_is_a_clean_error() {
let x = Array::from_vec(vec![1.0, f64::NAN, 3.0]);
let y = Array::from_vec(vec![1.0, 2.0, 3.0]);
assert!(
histogram2d(&x, &y, 3, None, None, None).is_err(),
"NaN in x with auto-range should error"
);
assert!(
histogram2d(&y, &x, 3, None, None, None).is_err(),
"NaN in y with auto-range should error"
);
}
#[test]
fn test_histogram2d_density_matches_numpy() {
let x = Array::from_vec(vec![1.0, 2.0, 2.0, 3.0]);
let y = Array::from_vec(vec![1.0, 1.0, 2.0, 2.0]);
let (counts, x_edges, y_edges) =
histogram2d(&x, &y, 2, None, None, None).expect("counts should succeed");
assert_vec_close(&counts.to_vec(), &[1.0, 0.0, 1.0, 2.0], "h2d_counts");
assert_vec_close(&x_edges.to_vec(), &[1.0, 2.0, 3.0], "h2d_x_edges");
assert_vec_close(&y_edges.to_vec(), &[1.0, 1.5, 2.0], "h2d_y_edges");
let (density, _, _) =
histogram2d(&x, &y, 2, None, None, Some(true)).expect("density should succeed");
assert_vec_close(&density.to_vec(), &[0.5, 0.0, 0.5, 1.0], "h2d_density");
}
#[test]
fn test_histogramdd_matches_numpy_counts_and_edges() {
let sample = Array::from_vec(vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 0.3, 0.7]).reshape(&[4, 2]);
let (hist, edges) =
histogramdd(&sample, &[2, 2], None, None, None).expect("histogramdd should succeed");
assert_eq!(hist.shape(), vec![2, 2]);
assert_vec_close(&hist.to_vec(), &[1.0, 1.0, 0.0, 2.0], "histogramdd_counts");
assert_vec_close(
&edges[0].to_vec(),
&[0.0, 0.5, 1.0],
"histogramdd_edges_dim0",
);
assert_vec_close(
&edges[1].to_vec(),
&[0.0, 0.5, 1.0],
"histogramdd_edges_dim1",
);
}
#[test]
fn test_histogramdd_density_matches_histogram2d_density() {
let sample = Array::from_vec(vec![1.0, 1.0, 2.0, 1.0, 2.0, 2.0, 3.0, 2.0]).reshape(&[4, 2]);
let (density, _) =
histogramdd(&sample, &[2, 2], None, None, Some(true)).expect("density should succeed");
assert_vec_close(
&density.to_vec(),
&[0.5, 0.0, 0.5, 1.0],
"histogramdd_density",
);
}
#[test]
fn test_histogram_dd_alias_delegates_to_histogramdd() {
let sample = Array::from_vec(vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 0.3, 0.7]).reshape(&[4, 2]);
let (hist_alias, edges_alias) =
histogram_dd(&sample, &[2, 2], None, None).expect("histogram_dd should succeed");
let (hist_canonical, edges_canonical) =
histogramdd(&sample, &[2, 2], None, None, None).expect("histogramdd should succeed");
assert_eq!(hist_alias.to_vec(), hist_canonical.to_vec());
for (a, c) in edges_alias.iter().zip(edges_canonical.iter()) {
assert_eq!(a.to_vec(), c.to_vec());
}
}
#[test]
fn test_histogramdd_no_longer_shifts_bin_edges_by_epsilon() {
let sample = Array::from_vec(vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0]).reshape(&[3, 2]);
let (_, edges) =
histogramdd(&sample, &[2, 2], None, None, None).expect("histogramdd should succeed");
assert_eq!(edges[0].to_vec(), vec![0.0, 1.0, 2.0]);
assert_eq!(edges[1].to_vec(), vec![0.0, 1.0, 2.0]);
}
#[test]
fn test_histogramdd_nan_sample_excluded_with_explicit_range() {
let sample = Array::from_vec(vec![0.0, 0.0, f64::NAN, 0.5, 1.0, 1.0]).reshape(&[3, 2]);
let (hist, _) = histogramdd(
&sample,
&[2, 2],
Some(vec![(0.0, 1.0), (0.0, 1.0)]),
None,
None,
)
.expect("NaN row should be excluded, not panic");
let total: f64 = hist.to_vec().iter().sum();
assert_vec_close(&[total], &[2.0], "nan_excluded_total");
assert_vec_close(&hist.to_vec(), &[1.0, 0.0, 0.0, 1.0], "nan_excluded_counts");
}
#[test]
fn test_histogramdd_nan_with_auto_range_is_a_clean_error_not_a_panic() {
let sample = Array::from_vec(vec![0.0, 0.0, f64::NAN, 0.5, 1.0, 1.0]).reshape(&[3, 2]);
let result = histogramdd(&sample, &[2, 2], None, None, None);
assert!(
result.is_err(),
"auto-range over NaN data should error, not panic or succeed"
);
}
#[test]
fn test_histogramdd_explicit_nan_range_bound_is_rejected() {
let sample = Array::from_vec(vec![1.0, 1.0, 2.0, 2.0]).reshape(&[2, 2]);
let result = histogramdd(
&sample,
&[2, 2],
Some(vec![(f64::NAN, 5.0), (0.0, 5.0)]),
None,
None,
);
assert!(
result.is_err(),
"explicit NaN range bound should error, not panic or succeed"
);
}