1use crate::Float;
34use kiddo::{KdTree, SquaredEuclidean};
35use nalgebra::{Point2, RealField};
36
37#[non_exhaustive]
39#[derive(Clone, Copy, Debug)]
40pub struct GlobalStepEstimate<F: Float = f32> {
41 pub cell_size: F,
43 pub support: usize,
47 pub sample_count: usize,
51 pub confidence: F,
54 pub multimodal: bool,
62}
63
64#[derive(Clone, Copy, Debug)]
66pub struct GlobalStepParams<F: Float = f32> {
67 pub bandwidth_rel: F,
70 pub max_iters: u32,
73 pub convergence_rel: F,
76}
77
78impl<F: Float> Default for GlobalStepParams<F> {
79 fn default() -> Self {
80 Self {
81 bandwidth_rel: F::from_subset(&0.15),
82 max_iters: 20,
83 convergence_rel: F::from_subset(&1e-3),
84 }
85 }
86}
87
88#[cfg_attr(
93 feature = "tracing",
94 tracing::instrument(
95 level = "debug",
96 skip_all,
97 fields(num_points = positions.len()),
98 )
99)]
100pub fn estimate_global_cell_size<F: Float + kiddo::float::kdtree::Axis>(
101 positions: &[Point2<F>],
102 params: &GlobalStepParams<F>,
103) -> Option<GlobalStepEstimate<F>> {
104 if positions.len() < 2 {
105 return None;
106 }
107
108 let coords: Vec<[F; 2]> = positions.iter().map(|p| [p.x, p.y]).collect();
109 let tree: KdTree<F, 2> = (&coords).into();
110
111 let mut nn_distances: Vec<F> = Vec::with_capacity(positions.len());
112 for (i, p) in positions.iter().enumerate() {
113 let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], 2);
114 for hit in hits {
115 let j = hit.item as usize;
116 if j == i {
117 continue;
118 }
119 let d2 = hit.distance;
120 if d2 > F::zero() {
121 nn_distances.push(d2.sqrt());
122 }
123 break;
124 }
125 }
126
127 if nn_distances.is_empty() {
128 return None;
129 }
130 nn_distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
131
132 let sample_count = nn_distances.len();
133 let seeds = [
134 percentile_sorted(&nn_distances, F::from_subset(&0.25)),
135 percentile_sorted(&nn_distances, F::from_subset(&0.5)),
136 percentile_sorted(&nn_distances, F::from_subset(&0.75)),
137 ];
138
139 let mut best: Option<(F, usize, F)> = None; let mut converged_modes: Vec<F> = Vec::new();
145 for seed in seeds {
146 if let Some((mode, support_u32)) = mean_shift_mode(&nn_distances, seed, params) {
147 if support_u32 == 0 {
148 continue;
149 }
150 let support = support_u32 as usize;
151 converged_modes.push(mode);
152 let score = F::from_subset(&(support as f64)) * mode;
153 if best.map(|b: (F, usize, F)| score > b.2).unwrap_or(true) {
154 best = Some((mode, support, score));
155 }
156 }
157 }
158 let (cell_size, support, _) = best?;
159 let confidence = RealField::max(
160 RealField::min(
161 F::from_subset(&(support as f64)) / F::from_subset(&(sample_count as f64)),
162 F::one(),
163 ),
164 F::zero(),
165 );
166
167 let bandwidth = cell_size * params.bandwidth_rel;
171 let multimodal = converged_modes.iter().any(|&m| {
172 let diff: F = m - cell_size;
173 let abs_diff: F = if diff < F::zero() { -diff } else { diff };
174 abs_diff > bandwidth
175 });
176
177 Some(GlobalStepEstimate {
178 cell_size,
179 support,
180 sample_count,
181 confidence,
182 multimodal,
183 })
184}
185
186fn percentile_sorted<F: Float>(sorted: &[F], q: F) -> F {
187 let len = sorted.len();
188 if len == 0 {
189 return F::zero();
190 }
191 let idx_f = q * F::from_subset(&((len - 1) as f64));
192 let idx = idx_f.floor();
193 let i = idx.to_subset().unwrap_or(0.0) as usize;
194 let i = i.min(len - 1);
195 sorted[i]
196}
197
198fn mean_shift_mode<F: Float>(
199 sorted: &[F],
200 seed: F,
201 params: &GlobalStepParams<F>,
202) -> Option<(F, u32)> {
203 if seed <= F::zero() {
204 return None;
205 }
206 let bandwidth = seed * params.bandwidth_rel;
207 if bandwidth <= F::zero() {
208 return Some((seed, 0));
209 }
210 let convergence = bandwidth * params.convergence_rel;
211
212 let mut center = seed;
213 for _ in 0..params.max_iters {
214 let mut sum = F::zero();
215 let mut weight = F::zero();
216 let mut count_in_band = 0u32;
217 for &v in sorted {
218 let diff = v - center;
219 if diff.abs() > bandwidth {
220 continue;
221 }
222 let t = diff / bandwidth;
223 let w = F::one() - t * t;
224 let w = if w < F::zero() { F::zero() } else { w };
225 if w > F::zero() {
226 sum += v * w;
227 weight += w;
228 count_in_band += 1;
229 }
230 }
231 if weight <= F::zero() {
232 return Some((center, 0));
233 }
234 let next = sum / weight;
235 if (next - center).abs() <= convergence {
236 return Some((next, count_in_band));
237 }
238 center = next;
239 }
240 let mut in_band = 0u32;
242 for &v in sorted {
243 if (v - center).abs() <= bandwidth {
244 in_band += 1;
245 }
246 }
247 Some((center, in_band))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn rectangular_grid(rows: u32, cols: u32, spacing: f32) -> Vec<Point2<f32>> {
255 let mut out = Vec::new();
256 for j in 0..rows {
257 for i in 0..cols {
258 out.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
259 }
260 }
261 out
262 }
263
264 #[test]
265 fn recovers_regular_grid_spacing() {
266 let params = GlobalStepParams::<f32>::default();
267 for &spacing in &[10.0_f32, 24.0, 50.0] {
268 let pts = rectangular_grid(5, 5, spacing);
269 let est = estimate_global_cell_size(&pts, ¶ms).expect("estimate");
270 assert!(
271 (est.cell_size - spacing).abs() / spacing < 0.02,
272 "spacing {spacing}: estimate {} off >2 %",
273 est.cell_size
274 );
275 assert!(est.confidence > 0.9, "confidence {}", est.confidence);
276 }
277 }
278
279 #[test]
280 fn sparse_noise_does_not_drag_mode() {
281 let mut pts = rectangular_grid(5, 5, 24.0);
285 for (dx, dy) in [(6.0, 9.0), (43.0, 9.0), (9.0, 43.0), (81.0, 81.0)] {
287 pts.push(Point2::new(dx, dy));
288 }
289 let est =
290 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
291 assert!(
292 (est.cell_size - 24.0).abs() < 2.0,
293 "expected board step ~24 but got {}",
294 est.cell_size
295 );
296 assert!(est.support >= 10); }
298
299 #[test]
300 fn bimodal_density_weights_by_cell_size() {
301 let mut pts = Vec::new();
305 for j in 0..4 {
306 for i in 0..4 {
307 pts.push(Point2::new(i as f32 * 4.0, j as f32 * 4.0));
308 }
309 }
310 for j in 0..4 {
311 for i in 0..4 {
312 pts.push(Point2::new(
313 1000.0 + i as f32 * 40.0,
314 1000.0 + j as f32 * 40.0,
315 ));
316 }
317 }
318 let est =
319 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
320 assert!(
321 (est.cell_size - 40.0).abs() < 4.0,
322 "expected larger-grid cell ~40 but got {}",
323 est.cell_size
324 );
325 assert!(est.multimodal, "expected multimodal=true on bimodal cloud");
329 }
330
331 #[test]
332 fn unimodal_grid_has_multimodal_false() {
333 let pts = rectangular_grid(7, 7, 25.0);
334 let est =
335 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
336 assert!(!est.multimodal, "expected multimodal=false on a clean grid");
337 }
338
339 #[test]
340 fn too_small_input_returns_none() {
341 let pts: Vec<Point2<f32>> = vec![];
342 assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
343 let pts = vec![Point2::new(0.0, 0.0)];
344 assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
345 }
346
347 #[test]
348 fn degenerate_duplicate_points_are_skipped() {
349 let pts = vec![
350 Point2::new(0.0, 0.0),
351 Point2::new(0.0, 0.0),
352 Point2::new(10.0, 0.0),
353 Point2::new(0.0, 10.0),
354 Point2::new(10.0, 10.0),
355 ];
356 let est =
357 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
358 assert!((est.cell_size - 10.0).abs() < 1.0);
359 }
360
361 #[test]
362 fn mild_jitter_still_recovers_mode() {
363 let pts: Vec<Point2<f32>> = rectangular_grid(5, 5, 24.0)
365 .into_iter()
366 .enumerate()
367 .map(|(i, p)| {
368 let jitter_x = ((i * 17 % 7) as f32 - 3.0) * 0.4;
369 let jitter_y = ((i * 23 % 9) as f32 - 4.0) * 0.4;
370 Point2::new(p.x + jitter_x, p.y + jitter_y)
371 })
372 .collect();
373 let est =
374 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
375 assert!(
376 (est.cell_size - 24.0).abs() < 2.0,
377 "expected ~24 got {}",
378 est.cell_size
379 );
380 }
381}