Skip to main content

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}