1use crate::Float;
34use kiddo::{KdTree, SquaredEuclidean};
35use nalgebra::{Point2, RealField};
36
37#[derive(Clone, Copy, Debug)]
39pub struct GlobalStepEstimate<F: Float = f32> {
40 pub cell_size: F,
42 pub support: u32,
46 pub sample_count: u32,
50 pub confidence: F,
53}
54
55#[derive(Clone, Copy, Debug)]
57pub struct GlobalStepParams<F: Float = f32> {
58 pub bandwidth_rel: F,
61 pub max_iters: u32,
64 pub convergence_rel: F,
67}
68
69impl<F: Float> Default for GlobalStepParams<F> {
70 fn default() -> Self {
71 Self {
72 bandwidth_rel: F::from_subset(&0.15),
73 max_iters: 20,
74 convergence_rel: F::from_subset(&1e-3),
75 }
76 }
77}
78
79pub fn estimate_global_cell_size<F: Float + kiddo::float::kdtree::Axis>(
84 positions: &[Point2<F>],
85 params: &GlobalStepParams<F>,
86) -> Option<GlobalStepEstimate<F>> {
87 if positions.len() < 2 {
88 return None;
89 }
90
91 let coords: Vec<[F; 2]> = positions.iter().map(|p| [p.x, p.y]).collect();
92 let tree: KdTree<F, 2> = (&coords).into();
93
94 let mut nn_distances: Vec<F> = Vec::with_capacity(positions.len());
95 for (i, p) in positions.iter().enumerate() {
96 let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], 2);
97 for hit in hits {
98 let j = hit.item as usize;
99 if j == i {
100 continue;
101 }
102 let d2 = hit.distance;
103 if d2 > F::zero() {
104 nn_distances.push(d2.sqrt());
105 }
106 break;
107 }
108 }
109
110 if nn_distances.is_empty() {
111 return None;
112 }
113 nn_distances.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
114
115 let sample_count = nn_distances.len() as u32;
116 let seeds = [
117 percentile_sorted(&nn_distances, F::from_subset(&0.25)),
118 percentile_sorted(&nn_distances, F::from_subset(&0.5)),
119 percentile_sorted(&nn_distances, F::from_subset(&0.75)),
120 ];
121
122 let mut best: Option<(F, u32, F)> = None; for seed in seeds {
128 if let Some((mode, support)) = mean_shift_mode(&nn_distances, seed, params) {
129 if support == 0 {
130 continue;
131 }
132 let score = F::from_subset(&(support as f64)) * mode;
133 if best.map(|b: (F, u32, F)| score > b.2).unwrap_or(true) {
134 best = Some((mode, support, score));
135 }
136 }
137 }
138 let (cell_size, support, _) = best?;
139 let confidence = RealField::max(
140 RealField::min(
141 F::from_subset(&(support as f64)) / F::from_subset(&(sample_count as f64)),
142 F::one(),
143 ),
144 F::zero(),
145 );
146
147 Some(GlobalStepEstimate {
148 cell_size,
149 support,
150 sample_count,
151 confidence,
152 })
153}
154
155fn percentile_sorted<F: Float>(sorted: &[F], q: F) -> F {
156 let len = sorted.len();
157 if len == 0 {
158 return F::zero();
159 }
160 let idx_f = q * F::from_subset(&((len - 1) as f64));
161 let idx = idx_f.floor();
162 let i = idx.to_subset().unwrap_or(0.0) as usize;
163 let i = i.min(len - 1);
164 sorted[i]
165}
166
167fn mean_shift_mode<F: Float>(
168 sorted: &[F],
169 seed: F,
170 params: &GlobalStepParams<F>,
171) -> Option<(F, u32)> {
172 if seed <= F::zero() {
173 return None;
174 }
175 let bandwidth = seed * params.bandwidth_rel;
176 if bandwidth <= F::zero() {
177 return Some((seed, 0));
178 }
179 let convergence = bandwidth * params.convergence_rel;
180
181 let mut center = seed;
182 for _ in 0..params.max_iters {
183 let mut sum = F::zero();
184 let mut weight = F::zero();
185 let mut count_in_band = 0u32;
186 for &v in sorted {
187 let diff = v - center;
188 if diff.abs() > bandwidth {
189 continue;
190 }
191 let t = diff / bandwidth;
192 let w = F::one() - t * t;
193 let w = if w < F::zero() { F::zero() } else { w };
194 if w > F::zero() {
195 sum += v * w;
196 weight += w;
197 count_in_band += 1;
198 }
199 }
200 if weight <= F::zero() {
201 return Some((center, 0));
202 }
203 let next = sum / weight;
204 if (next - center).abs() <= convergence {
205 return Some((next, count_in_band));
206 }
207 center = next;
208 }
209 let mut in_band = 0u32;
211 for &v in sorted {
212 if (v - center).abs() <= bandwidth {
213 in_band += 1;
214 }
215 }
216 Some((center, in_band))
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn rectangular_grid(rows: u32, cols: u32, spacing: f32) -> Vec<Point2<f32>> {
224 let mut out = Vec::new();
225 for j in 0..rows {
226 for i in 0..cols {
227 out.push(Point2::new(i as f32 * spacing, j as f32 * spacing));
228 }
229 }
230 out
231 }
232
233 #[test]
234 fn recovers_regular_grid_spacing() {
235 let params = GlobalStepParams::<f32>::default();
236 for &spacing in &[10.0_f32, 24.0, 50.0] {
237 let pts = rectangular_grid(5, 5, spacing);
238 let est = estimate_global_cell_size(&pts, ¶ms).expect("estimate");
239 assert!(
240 (est.cell_size - spacing).abs() / spacing < 0.02,
241 "spacing {spacing}: estimate {} off >2 %",
242 est.cell_size
243 );
244 assert!(est.confidence > 0.9, "confidence {}", est.confidence);
245 }
246 }
247
248 #[test]
249 fn sparse_noise_does_not_drag_mode() {
250 let mut pts = rectangular_grid(5, 5, 24.0);
254 for (dx, dy) in [(6.0, 9.0), (43.0, 9.0), (9.0, 43.0), (81.0, 81.0)] {
256 pts.push(Point2::new(dx, dy));
257 }
258 let est =
259 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
260 assert!(
261 (est.cell_size - 24.0).abs() < 2.0,
262 "expected board step ~24 but got {}",
263 est.cell_size
264 );
265 assert!(est.support >= 10); }
267
268 #[test]
269 fn bimodal_density_weights_by_cell_size() {
270 let mut pts = Vec::new();
274 for j in 0..4 {
275 for i in 0..4 {
276 pts.push(Point2::new(i as f32 * 4.0, j as f32 * 4.0));
277 }
278 }
279 for j in 0..4 {
280 for i in 0..4 {
281 pts.push(Point2::new(
282 1000.0 + i as f32 * 40.0,
283 1000.0 + j as f32 * 40.0,
284 ));
285 }
286 }
287 let est =
288 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
289 assert!(
290 (est.cell_size - 40.0).abs() < 4.0,
291 "expected larger-grid cell ~40 but got {}",
292 est.cell_size
293 );
294 }
295
296 #[test]
297 fn too_small_input_returns_none() {
298 let pts: Vec<Point2<f32>> = vec![];
299 assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
300 let pts = vec![Point2::new(0.0, 0.0)];
301 assert!(estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).is_none());
302 }
303
304 #[test]
305 fn degenerate_duplicate_points_are_skipped() {
306 let pts = vec![
307 Point2::new(0.0, 0.0),
308 Point2::new(0.0, 0.0),
309 Point2::new(10.0, 0.0),
310 Point2::new(0.0, 10.0),
311 Point2::new(10.0, 10.0),
312 ];
313 let est =
314 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
315 assert!((est.cell_size - 10.0).abs() < 1.0);
316 }
317
318 #[test]
319 fn mild_jitter_still_recovers_mode() {
320 let pts: Vec<Point2<f32>> = rectangular_grid(5, 5, 24.0)
322 .into_iter()
323 .enumerate()
324 .map(|(i, p)| {
325 let jitter_x = ((i * 17 % 7) as f32 - 3.0) * 0.4;
326 let jitter_y = ((i * 23 % 9) as f32 - 4.0) * 0.4;
327 Point2::new(p.x + jitter_x, p.y + jitter_y)
328 })
329 .collect();
330 let est =
331 estimate_global_cell_size(&pts, &GlobalStepParams::<f32>::default()).expect("estimate");
332 assert!(
333 (est.cell_size - 24.0).abs() < 2.0,
334 "expected ~24 got {}",
335 est.cell_size
336 );
337 }
338}