pub const X_SCALE: f64 = 0.6;
pub const Z_SCALE: f64 = 0.5;
pub const PEAK_THRESHOLD: f64 = 0.35;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FieldPeak {
pub position: [f64; 3],
pub intensity: f64,
pub cell: (usize, usize),
}
#[must_use]
pub fn cell_to_world(ix: usize, iz: usize, nx: usize, nz: usize) -> [f64; 3] {
let wx = (ix as f64 - nx as f64 / 2.0) * X_SCALE;
let wz = (iz as f64 - nz as f64 / 2.0) * Z_SCALE;
[wx, 0.0, wz]
}
#[must_use]
pub fn extract_peaks(
values: &[f64],
nx: usize,
nz: usize,
max_peaks: usize,
min_separation_cells: f64,
) -> Vec<FieldPeak> {
if nx == 0 || nz == 0 || values.len() < nx * nz || max_peaks == 0 {
return Vec::new();
}
let mut candidates: Vec<(usize, usize, f64)> = Vec::new();
for iz in 0..nz {
for ix in 0..nx {
let v = values[iz * nx + ix];
if v >= PEAK_THRESHOLD {
candidates.push((ix, iz, v));
}
}
}
candidates.sort_by(|a, b| b.2.total_cmp(&a.2));
let mut peaks: Vec<FieldPeak> = Vec::new();
for (ix, iz, v) in candidates {
if peaks.len() >= max_peaks {
break;
}
let too_close = peaks.iter().any(|p| {
let dx = p.cell.0 as f64 - ix as f64;
let dz = p.cell.1 as f64 - iz as f64;
(dx * dx + dz * dz).sqrt() < min_separation_cells
});
if too_close {
continue;
}
peaks.push(FieldPeak {
position: cell_to_world(ix, iz, nx, nz),
intensity: v,
cell: (ix, iz),
});
}
peaks
}
#[must_use]
pub fn motion_score_from_power(motion_band_power: f64) -> f64 {
motion_band_power.clamp(0.0, 100.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cell_to_world_matches_observatory_layout() {
let c = cell_to_world(10, 10, 20, 20);
assert!((c[0] - 0.0).abs() < 1e-9);
assert_eq!(c[1], 0.0);
assert!((c[2] - 0.0).abs() < 1e-9);
let corner = cell_to_world(0, 0, 20, 20);
assert!((corner[0] - (-6.0)).abs() < 1e-9); assert!((corner[2] - (-5.0)).abs() < 1e-9); }
#[test]
fn extract_peaks_finds_known_hotspot() {
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
let peak_ix = 15;
let peak_iz = 4;
values[peak_iz * nx + peak_ix] = 1.0;
let peaks = extract_peaks(&values, nx, nz, 1, 3.0);
assert_eq!(peaks.len(), 1);
assert_eq!(peaks[0].cell, (peak_ix, peak_iz));
let expected = cell_to_world(peak_ix, peak_iz, nx, nz);
assert!((peaks[0].position[0] - expected[0]).abs() < 1e-9);
assert!((peaks[0].position[2] - expected[2]).abs() < 1e-9);
assert!((peaks[0].position[0] - 3.0).abs() < 1e-9);
assert!((peaks[0].position[2] - (-3.0)).abs() < 1e-9);
}
#[test]
fn empty_field_yields_no_peaks() {
let nx = 20;
let nz = 20;
let values = vec![0.10; nx * nz];
let peaks = extract_peaks(&values, nx, nz, 3, 3.0);
assert!(
peaks.is_empty(),
"below-threshold field must not produce a phantom peak"
);
}
#[test]
fn two_separated_peaks_do_not_collapse() {
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
values[2 * nx + 3] = 0.95; values[15 * nx + 17] = 0.90;
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
assert_eq!(peaks.len(), 2);
assert_eq!(peaks[0].cell, (3, 2));
assert_eq!(peaks[1].cell, (17, 15));
}
#[test]
fn nearby_secondary_peak_is_suppressed() {
let nx = 20;
let nz = 20;
let mut values = vec![0.05; nx * nz];
values[10 * nx + 10] = 1.00; values[10 * nx + 11] = 0.99;
let peaks = extract_peaks(&values, nx, nz, 2, 3.0);
assert_eq!(peaks.len(), 1, "adjacent cell must not become a 2nd person");
assert_eq!(peaks[0].cell, (10, 10));
}
#[test]
fn motion_score_passthrough_and_clamp() {
assert!((motion_score_from_power(63.3) - 63.3).abs() < 1e-9);
assert_eq!(motion_score_from_power(-5.0), 0.0);
assert_eq!(motion_score_from_power(250.0), 100.0);
}
}