tp_lib_core/path/spacing.rs
1//! Spacing calculation for GNSS positions
2//!
3//! Provides utilities for calculating mean spacing between consecutive GNSS positions,
4//! used for resampling optimization.
5
6use crate::models::GnssPosition;
7
8/// Calculate mean spacing between consecutive GNSS positions
9///
10/// Uses distance column values when available (from wheel sensors),
11/// otherwise falls back to geometric distance calculation.
12/// This is used for resampling to determine optimal sampling interval.
13///
14/// # Arguments
15///
16/// * `gnss_positions` - Slice of GNSS positions in temporal order
17///
18/// # Returns
19///
20/// Mean distance in meters between consecutive positions, or 0.0 if fewer than 2 positions
21///
22/// # Examples
23///
24/// ```
25/// use tp_lib_core::GnssPosition;
26/// use chrono::Utc;
27///
28/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
29/// let positions = vec![
30/// GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string())?,
31/// GnssPosition::new(50.8513, 4.3512, Utc::now().into(), "EPSG:4326".to_string())?,
32/// ];
33///
34/// let mean_spacing = tp_lib_core::calculate_mean_spacing(&positions);
35/// assert!(mean_spacing > 0.0);
36/// # Ok(())
37/// # }
38/// ```
39pub fn calculate_mean_spacing(gnss_positions: &[GnssPosition]) -> f64 {
40 if gnss_positions.len() < 2 {
41 return 0.0;
42 }
43
44 let mut total_distance = 0.0;
45 let mut count = 0;
46
47 for i in 0..gnss_positions.len() - 1 {
48 let curr = &gnss_positions[i];
49 let next = &gnss_positions[i + 1];
50
51 // Use distance column if available (T119, T128)
52 let spacing = if let (Some(curr_dist), Some(next_dist)) = (curr.distance, next.distance) {
53 // Distance column is cumulative, so calculate the difference
54 (next_dist - curr_dist).abs()
55 } else {
56 // Fall back to geometric distance calculation (T128)
57 use geo::{HaversineDistance, Point};
58 let p1 = Point::new(curr.longitude, curr.latitude);
59 let p2 = Point::new(next.longitude, next.latitude);
60 p1.haversine_distance(&p2)
61 };
62
63 total_distance += spacing;
64 count += 1;
65 }
66
67 if count > 0 {
68 total_distance / count as f64
69 } else {
70 0.0
71 }
72}
73
74/// Select a resampled subset of GNSS positions for path calculation
75///
76/// Takes every Nth position based on the resampling distance and mean spacing.
77/// This reduces computational load while maintaining path structure accuracy.
78///
79/// # Arguments
80///
81/// * `gnss_positions` - Full set of GNSS positions in temporal order
82/// * `resampling_distance` - Target distance between resampled positions (meters)
83///
84/// # Returns
85///
86/// Indices of positions to use for path calculation. Returns all indices if
87/// resampling is not beneficial (fewer than 3 positions, or step size < 2).
88///
89/// # Examples
90///
91/// ```
92/// use tp_lib_core::GnssPosition;
93/// use chrono::Utc;
94///
95/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
96/// // Create 100 positions at 1m spacing
97/// let positions: Vec<GnssPosition> = (0..100)
98/// .map(|i| {
99/// let mut pos = GnssPosition::new(
100/// 50.85 + i as f64 * 0.00001,
101/// 4.35,
102/// Utc::now().into(),
103/// "EPSG:4326".to_string()
104/// ).unwrap();
105/// pos.distance = Some(i as f64); // 1m spacing
106/// pos
107/// })
108/// .collect();
109///
110/// // Resample at 10m intervals
111/// let indices = tp_lib_core::select_resampled_subset(&positions, 10.0);
112/// // Approximately 10-12 positions selected (includes first and last)
113/// assert!(indices.len() >= 10 && indices.len() <= 12);
114/// assert_eq!(indices[0], 0); // First position always included
115/// assert_eq!(*indices.last().unwrap(), 99); // Last position always included
116/// # Ok(())
117/// # }
118/// ```
119pub fn select_resampled_subset(
120 gnss_positions: &[GnssPosition],
121 resampling_distance: f64,
122) -> Vec<usize> {
123 if gnss_positions.len() < 3 || resampling_distance <= 0.0 {
124 // Not enough positions or invalid distance - return all indices
125 return (0..gnss_positions.len()).collect();
126 }
127
128 let mean_spacing = calculate_mean_spacing(gnss_positions);
129
130 if mean_spacing <= 0.0 {
131 // Can't determine spacing - return all indices
132 return (0..gnss_positions.len()).collect();
133 }
134
135 // Calculate step size (how many positions to skip)
136 let step_size = (resampling_distance / mean_spacing).ceil() as usize;
137
138 if step_size < 2 {
139 // Resampling not beneficial - return all indices
140 return (0..gnss_positions.len()).collect();
141 }
142
143 // Select every Nth position using step_by
144 // Always include first position (index 0) and try to include last
145 let mut indices: Vec<usize> = (0..gnss_positions.len()).step_by(step_size).collect();
146
147 // Ensure last position is included if not already there
148 let last_idx = gnss_positions.len() - 1;
149 if indices.last() != Some(&last_idx) && last_idx > 0 {
150 indices.push(last_idx);
151 }
152
153 indices
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use chrono::Utc;
160
161 #[test]
162 fn test_mean_spacing_with_distance_column() {
163 // T121: Test with distance column values (cumulative distance from wheel sensors)
164 let positions = vec![
165 GnssPosition::with_heading_distance(
166 50.8503,
167 4.3502,
168 Utc::now().into(),
169 "EPSG:4326".to_string(),
170 None,
171 Some(0.0), // Start at 0
172 )
173 .unwrap(),
174 GnssPosition::with_heading_distance(
175 50.8513,
176 4.3512,
177 Utc::now().into(),
178 "EPSG:4326".to_string(),
179 None,
180 Some(10.0), // 10m from start
181 )
182 .unwrap(),
183 GnssPosition::with_heading_distance(
184 50.8523,
185 4.3522,
186 Utc::now().into(),
187 "EPSG:4326".to_string(),
188 None,
189 Some(23.0), // 23m from start
190 )
191 .unwrap(),
192 ];
193
194 let mean = calculate_mean_spacing(&positions);
195 // Spacings: 10.0 - 0.0 = 10.0, 23.0 - 10.0 = 13.0
196 // Mean: (10.0 + 13.0) / 2 = 11.5
197 assert!(
198 (mean - 11.5).abs() < 0.001,
199 "Expected mean 11.5, got {}",
200 mean
201 );
202 }
203
204 #[test]
205 fn test_mean_spacing_without_distance_column() {
206 // T122: Test with geometric distance calculation
207 let positions = vec![
208 GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
209 GnssPosition::new(50.8513, 4.3512, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
210 GnssPosition::new(50.8523, 4.3522, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
211 ];
212
213 let mean = calculate_mean_spacing(&positions);
214 // Should calculate geometric distance using Haversine
215 assert!(mean > 0.0, "Mean spacing should be positive");
216 assert!(mean < 5000.0, "Mean spacing should be reasonable (< 5km)");
217 }
218
219 #[test]
220 fn test_mean_spacing_single_position() {
221 let positions =
222 vec![
223 GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string())
224 .unwrap(),
225 ];
226
227 let mean = calculate_mean_spacing(&positions);
228 assert_eq!(mean, 0.0, "Single position should return 0.0");
229 }
230
231 #[test]
232 fn test_mean_spacing_empty() {
233 let positions: Vec<GnssPosition> = vec![];
234
235 let mean = calculate_mean_spacing(&positions);
236 assert_eq!(mean, 0.0, "Empty positions should return 0.0");
237 }
238
239 #[test]
240 fn test_mean_spacing_mixed_distance_values() {
241 // Some positions have distance, some don't
242 let positions = vec![
243 GnssPosition::with_heading_distance(
244 50.8503,
245 4.3502,
246 Utc::now().into(),
247 "EPSG:4326".to_string(),
248 None,
249 Some(10.0),
250 )
251 .unwrap(),
252 GnssPosition::new(50.8513, 4.3512, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
253 GnssPosition::with_heading_distance(
254 50.8523,
255 4.3522,
256 Utc::now().into(),
257 "EPSG:4326".to_string(),
258 None,
259 Some(11.0),
260 )
261 .unwrap(),
262 ];
263
264 let mean = calculate_mean_spacing(&positions);
265 // Second position uses geometric distance, third uses distance value 11.0
266 assert!(
267 mean > 0.0,
268 "Mean spacing should be positive with mixed data"
269 );
270 }
271
272 // T135: Unit tests for resampled subset selection
273 #[test]
274 fn test_select_resampled_subset_basic() {
275 // Create 100 positions at 1m spacing
276 let positions: Vec<GnssPosition> = (0..100)
277 .map(|i| {
278 let mut pos = GnssPosition::new(
279 50.85 + i as f64 * 0.00001,
280 4.35,
281 Utc::now().into(),
282 "EPSG:4326".to_string(),
283 )
284 .unwrap();
285 pos.distance = Some(i as f64); // 1m spacing
286 pos
287 })
288 .collect();
289
290 // Resample at 10m intervals
291 let indices = select_resampled_subset(&positions, 10.0);
292
293 // Should select approximately every 10th position
294 // With 100 positions at 1m spacing, we expect ~10 positions
295 assert!(
296 indices.len() >= 10 && indices.len() <= 12,
297 "Should select ~10 positions, got {}",
298 indices.len()
299 );
300
301 // First and last should be included
302 assert_eq!(indices[0], 0, "First position should be included");
303 assert_eq!(
304 indices[indices.len() - 1],
305 99,
306 "Last position should be included"
307 );
308 }
309
310 #[test]
311 fn test_select_resampled_subset_no_resampling_needed() {
312 // Create 10 positions at 10m spacing
313 let positions: Vec<GnssPosition> = (0..10)
314 .map(|i| {
315 let mut pos = GnssPosition::new(
316 50.85 + i as f64 * 0.0001,
317 4.35,
318 Utc::now().into(),
319 "EPSG:4326".to_string(),
320 )
321 .unwrap();
322 pos.distance = Some(i as f64 * 10.0); // 10m spacing
323 pos
324 })
325 .collect();
326
327 // Resample at 10m intervals (same as data spacing)
328 let indices = select_resampled_subset(&positions, 10.0);
329
330 // Should return all indices (step size < 2)
331 assert_eq!(
332 indices.len(),
333 10,
334 "Should return all positions when resampling not beneficial"
335 );
336 }
337
338 #[test]
339 fn test_select_resampled_subset_too_few_positions() {
340 // Only 2 positions
341 let positions = vec![
342 GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
343 GnssPosition::new(50.8513, 4.3512, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
344 ];
345
346 let indices = select_resampled_subset(&positions, 10.0);
347
348 // Should return all indices (too few to resample)
349 assert_eq!(indices.len(), 2, "Should return all positions when too few");
350 assert_eq!(indices, vec![0, 1]);
351 }
352
353 #[test]
354 fn test_select_resampled_subset_invalid_distance() {
355 let positions: Vec<GnssPosition> = (0..10)
356 .map(|i| {
357 GnssPosition::new(
358 50.85 + i as f64 * 0.00001,
359 4.35,
360 Utc::now().into(),
361 "EPSG:4326".to_string(),
362 )
363 .unwrap()
364 })
365 .collect();
366
367 // Invalid resampling distance
368 let indices = select_resampled_subset(&positions, 0.0);
369 assert_eq!(
370 indices.len(),
371 10,
372 "Should return all positions for invalid distance"
373 );
374
375 let indices = select_resampled_subset(&positions, -5.0);
376 assert_eq!(
377 indices.len(),
378 10,
379 "Should return all positions for negative distance"
380 );
381 }
382
383 #[test]
384 fn test_select_resampled_subset_ensures_last_position() {
385 // Create positions where last won't naturally be selected
386 let positions: Vec<GnssPosition> = (0..99)
387 .map(|i| {
388 let mut pos = GnssPosition::new(
389 50.85 + i as f64 * 0.00001,
390 4.35,
391 Utc::now().into(),
392 "EPSG:4326".to_string(),
393 )
394 .unwrap();
395 pos.distance = Some(i as f64); // 1m spacing
396 pos
397 })
398 .collect();
399
400 let indices = select_resampled_subset(&positions, 10.0);
401
402 // Last position (index 98) should be included
403 assert_eq!(
404 indices[indices.len() - 1],
405 98,
406 "Last position should always be included"
407 );
408 }
409}