use molrs::types::F;
use ndarray::Array1;
#[derive(Debug, Clone)]
pub struct Histogram1d {
n_bins: usize,
min: F,
max: F,
bin_width: F,
fac: F,
counts: Array1<F>,
binned: F,
skipped: F,
sum: F,
sq_sum: F,
}
impl Histogram1d {
pub fn new(n_bins: usize, min: F, max: F) -> Self {
debug_assert!(n_bins >= 1, "histogram needs at least one bin");
debug_assert!(max > min, "histogram max must exceed min");
let bin_width = (max - min) / n_bins as F;
Self {
n_bins,
min,
max,
bin_width,
fac: n_bins as F / (max - min),
counts: Array1::zeros(n_bins),
binned: 0.0,
skipped: 0.0,
sum: 0.0,
sq_sum: 0.0,
}
}
pub fn add(&mut self, d: F) {
self.add_weighted(d, 1.0);
}
pub fn add_weighted(&mut self, d: F, w: F) {
self.sum += d * w;
self.sq_sum += d * d * w;
if d < self.min || d > self.max {
self.skipped += w;
return;
}
self.binned += w;
let (idx, wt) = cic_split(d, self.min, self.fac, self.n_bins);
self.counts[idx[0]] += wt[0] * w;
self.counts[idx[1]] += wt[1] * w;
}
pub fn add_nearest(&mut self, d: F) {
self.sum += d;
self.sq_sum += d * d;
if d < self.min || d > self.max {
self.skipped += 1.0;
return;
}
let mut ip = ((d - self.min) * self.fac) as usize;
if ip >= self.n_bins {
ip = self.n_bins - 1;
}
self.counts[ip] += 1.0;
self.binned += 1.0;
}
pub fn merge(&mut self, other: &Self) {
debug_assert_eq!(self.n_bins, other.n_bins, "merge: bin count mismatch");
debug_assert_eq!(self.min, other.min, "merge: min mismatch");
debug_assert_eq!(self.max, other.max, "merge: max mismatch");
self.counts += &other.counts;
self.binned += other.binned;
self.skipped += other.skipped;
self.sum += other.sum;
self.sq_sum += other.sq_sum;
}
pub fn edges(&self) -> Array1<F> {
Array1::from_iter((0..=self.n_bins).map(|i| self.min + i as F * self.bin_width))
}
pub fn centers(&self) -> Array1<F> {
Array1::from_iter((0..self.n_bins).map(|i| self.min + (i as F + 0.5) * self.bin_width))
}
pub fn counts(&self) -> &Array1<F> {
&self.counts
}
pub fn binned(&self) -> F {
self.binned
}
pub fn bin_width(&self) -> F {
self.bin_width
}
pub fn n_bins(&self) -> usize {
self.n_bins
}
pub fn density(&self) -> Array1<F> {
if self.binned <= 0.0 {
return Array1::zeros(self.n_bins);
}
let denom = self.binned * self.bin_width;
self.counts.mapv(|c| c / denom)
}
}
#[inline]
pub(crate) fn cic_split(d: F, min: F, fac: F, n_bins: usize) -> ([usize; 2], [F; 2]) {
if n_bins == 1 {
return ([0, 0], [1.0, 0.0]);
}
let praw = (d - min) * fac - 0.5;
let ipf = praw.floor();
let (ip, frac) = if ipf < 0.0 {
(0usize, 0.0)
} else if ipf > (n_bins - 2) as F {
(n_bins - 2, 1.0)
} else {
(ipf as usize, praw - ipf)
};
([ip, ip + 1], [1.0 - frac, frac])
}
pub fn renormalize_density(weights: &Array1<F>, bin_width: F) -> Array1<F> {
let total: F = weights.sum() * bin_width;
if total <= 0.0 || !total.is_finite() {
return Array1::zeros(weights.len());
}
weights.mapv(|w| w / total)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binning_matches_reference_indexing() {
let mut h = Histogram1d::new(4, 0.0, 4.0);
h.add(0.5); h.add(1.5); h.add(3.9); h.add(4.0); assert_eq!(h.counts()[0], 1.0);
assert_eq!(h.counts()[1], 1.0);
assert_eq!(h.counts()[3], 2.0);
assert_eq!(h.binned(), 4.0);
}
#[test]
fn cloud_in_cell_splits_between_bin_centers() {
let mut h = Histogram1d::new(4, 0.0, 4.0);
h.add(1.0);
assert!((h.counts()[0] - 0.5).abs() < 1e-12);
assert!((h.counts()[1] - 0.5).abs() < 1e-12);
assert_eq!(h.binned(), 1.0);
let mut h2 = Histogram1d::new(4, 0.0, 4.0);
h2.add(1.75);
assert!((h2.counts()[1] - 0.75).abs() < 1e-12);
assert!((h2.counts()[2] - 0.25).abs() < 1e-12);
}
#[test]
fn out_of_range_is_skipped_not_binned() {
let mut h = Histogram1d::new(4, 0.0, 4.0);
h.add(-1.0);
h.add(5.0);
assert_eq!(h.binned(), 0.0);
assert!(h.counts().iter().all(|&c| c == 0.0));
}
#[test]
fn density_integrates_to_one() {
let mut h = Histogram1d::new(10, 0.0, 10.0);
for i in 0..100 {
h.add((i % 10) as F + 0.5);
}
let d = h.density();
let integral: F = d.iter().map(|&p| p * h.bin_width()).sum();
assert!((integral - 1.0).abs() < 1e-12, "integral = {integral}");
}
}