Skip to main content

gpx_rs/gpx/analysis/
waypoint_path.rs

1use std::time::Duration;
2
3use crate::gpx::geo::distance_between;
4use crate::gpx::types::{Route, Track, TrackSegment, Waypoint};
5
6use super::{AnalysisOptions, ProfilePoint, SpeedProfilePoint};
7
8/// A connected sequence of waypoints for distance, speed, and elevation analysis.
9#[derive(Debug, Clone, PartialEq)]
10pub struct WaypointPath {
11    points: Vec<Waypoint>,
12    options: AnalysisOptions,
13}
14
15impl WaypointPath {
16    /// Creates a path from an owned list of waypoints.
17    pub fn new(points: Vec<Waypoint>) -> Self {
18        Self {
19            points,
20            options: AnalysisOptions::default(),
21        }
22    }
23
24    /// Creates a path from a slice of waypoints.
25    pub fn from_slice(points: &[Waypoint]) -> Self {
26        Self::new(points.to_vec())
27    }
28
29    /// Returns a copy of this path with custom analysis thresholds.
30    pub fn with_options(mut self, options: AnalysisOptions) -> Self {
31        self.options = options;
32        self
33    }
34
35    /// One `WaypointPath` per track segment, preserving GPX gap semantics.
36    pub fn from_track(track: &Track) -> Vec<WaypointPath> {
37        track.segments.iter().map(WaypointPath::from).collect()
38    }
39
40    /// Ordered waypoints in this path.
41    pub fn points(&self) -> &[Waypoint] {
42        &self.points
43    }
44
45    /// Number of waypoints in this path.
46    pub fn len(&self) -> usize {
47        self.points.len()
48    }
49
50    /// Whether this path contains no waypoints.
51    pub fn is_empty(&self) -> bool {
52        self.points.is_empty()
53    }
54
55    /// Total horizontal distance along the path, in meters.
56    pub fn total_distance(&self) -> f64 {
57        self.leg_distances().iter().sum()
58    }
59
60    /// Elapsed time from the first to the last timestamped point.
61    pub fn duration(&self) -> Option<Duration> {
62        let first = self.points.first()?.time?;
63        let last = self.points.last()?.time?;
64        duration_between(first, last)
65    }
66
67    /// Time spent moving (legs at or above the moving-speed threshold).
68    pub fn moving_duration(&self) -> Option<Duration> {
69        let speeds = self.leg_speeds();
70        let durations = self.leg_durations();
71
72        let mut total_ms = 0u64;
73        let mut has_timed_leg = false;
74
75        for (speed, duration) in speeds.iter().zip(durations.iter()) {
76            let Some(duration) = duration else {
77                continue;
78            };
79            has_timed_leg = true;
80            if speed.is_some_and(|s| s > self.options.moving_speed_threshold_mps) {
81                total_ms += duration.as_millis() as u64;
82            }
83        }
84
85        if has_timed_leg {
86            Some(Duration::from_millis(total_ms))
87        } else {
88            None
89        }
90    }
91
92    /// Average speed over the full duration, in m/s.
93    pub fn average_speed(&self) -> Option<f64> {
94        let secs = self.duration()?.as_secs_f64();
95        if secs > 0.0 {
96            Some(self.total_distance() / secs)
97        } else {
98            None
99        }
100    }
101
102    /// Average speed over moving time only, in m/s.
103    pub fn average_moving_speed(&self) -> Option<f64> {
104        let secs = self.moving_duration()?.as_secs_f64();
105        if secs > 0.0 {
106            Some(self.total_distance() / secs)
107        } else {
108            None
109        }
110    }
111
112    /// Instantaneous speed for each leg (length = points - 1), in m/s.
113    pub fn speeds(&self) -> Vec<Option<f64>> {
114        self.leg_speeds()
115    }
116
117    /// Maximum leg speed, in m/s.
118    pub fn max_speed(&self) -> Option<f64> {
119        self.speeds().iter().filter_map(|s| *s).reduce(f64::max)
120    }
121
122    /// Minimum leg speed, in m/s.
123    pub fn min_speed(&self) -> Option<f64> {
124        self.speeds().iter().filter_map(|s| *s).reduce(f64::min)
125    }
126
127    /// Timestamp versus speed at the start of each timed leg.
128    pub fn speed_profile(&self) -> Vec<SpeedProfilePoint> {
129        self.points
130            .windows(2)
131            .enumerate()
132            .filter_map(|(i, window)| {
133                let time = window[0].time?;
134                let speed = self.leg_speeds().get(i).copied()??;
135                Some(SpeedProfilePoint {
136                    time,
137                    speed_mps: speed,
138                })
139            })
140            .collect()
141    }
142
143    /// Per-point elevation values, when present.
144    pub fn elevations(&self) -> Vec<Option<f64>> {
145        self.points.iter().map(|p| p.ele).collect()
146    }
147
148    /// Mean elevation over points that have elevation data.
149    pub fn average_elevation(&self) -> Option<f64> {
150        let elevations: Vec<f64> = self.points.iter().filter_map(|p| p.ele).collect();
151        if elevations.is_empty() {
152            None
153        } else {
154            Some(elevations.iter().sum::<f64>() / elevations.len() as f64)
155        }
156    }
157
158    /// Highest elevation among points with elevation data.
159    pub fn max_elevation(&self) -> Option<f64> {
160        self.points.iter().filter_map(|p| p.ele).reduce(f64::max)
161    }
162
163    /// Lowest elevation among points with elevation data.
164    pub fn min_elevation(&self) -> Option<f64> {
165        self.points.iter().filter_map(|p| p.ele).reduce(f64::min)
166    }
167
168    /// Elevation range from lowest to highest point with elevation data.
169    pub fn elevation_difference(&self) -> Option<f64> {
170        Some(self.max_elevation()? - self.min_elevation()?)
171    }
172
173    /// Alias for [`elevation_difference`](Self::elevation_difference).
174    pub fn diff_elevation(&self) -> Option<f64> {
175        self.elevation_difference()
176    }
177
178    /// Total uphill elevation gain between consecutive points with elevation data.
179    pub fn total_ascent(&self) -> Option<f64> {
180        self.elevation_gains().map(|gains| {
181            let threshold = self.options.elevation_noise_threshold_m;
182            gains
183                .iter()
184                .filter(|&&gain| gain > threshold)
185                .sum()
186        })
187    }
188
189    /// Total downhill elevation loss between consecutive points with elevation data.
190    pub fn total_descent(&self) -> Option<f64> {
191        self.elevation_gains().map(|gains| {
192            let threshold = self.options.elevation_noise_threshold_m;
193            gains
194                .iter()
195                .filter(|&&gain| gain < -threshold)
196                .map(|gain| -gain)
197                .sum()
198        })
199    }
200
201    /// Distance versus elevation for points with elevation data.
202    ///
203    /// Distance is accumulated only between consecutive elevation-known points.
204    pub fn elevation_profile(&self) -> Vec<ProfilePoint> {
205        let points_with_ele: Vec<&Waypoint> =
206            self.points.iter().filter(|point| point.ele.is_some()).collect();
207        if points_with_ele.is_empty() {
208            return Vec::new();
209        }
210
211        let mut distance_m = 0.0;
212        let mut profile = vec![ProfilePoint {
213            distance_m,
214            value: points_with_ele[0].ele.unwrap(),
215        }];
216
217        for i in 1..points_with_ele.len() {
218            distance_m += distance_between(points_with_ele[i - 1], points_with_ele[i]);
219            profile.push(ProfilePoint {
220                distance_m,
221                value: points_with_ele[i].ele.unwrap(),
222            });
223        }
224
225        profile
226    }
227
228    fn leg_distances(&self) -> Vec<f64> {
229        self.points
230            .windows(2)
231            .map(|window| distance_between(&window[0], &window[1]))
232            .collect()
233    }
234
235    fn leg_durations(&self) -> Vec<Option<Duration>> {
236        self.points
237            .windows(2)
238            .map(|window| match (window[0].time, window[1].time) {
239                (Some(start), Some(end)) => duration_between(start, end),
240                _ => None,
241            })
242            .collect()
243    }
244
245    fn leg_speeds(&self) -> Vec<Option<f64>> {
246        self.leg_distances()
247            .iter()
248            .zip(self.leg_durations())
249            .map(|(distance, duration)| {
250                duration.and_then(|duration| {
251                    let secs = duration.as_secs_f64();
252                    if secs > 0.0 {
253                        Some(distance / secs)
254                    } else {
255                        None
256                    }
257                })
258            })
259            .collect()
260    }
261
262    fn elevation_gains(&self) -> Option<Vec<f64>> {
263        let points_with_ele: Vec<&Waypoint> =
264            self.points.iter().filter(|point| point.ele.is_some()).collect();
265        if points_with_ele.len() < 2 {
266            return None;
267        }
268        Some(
269            points_with_ele
270                .windows(2)
271                .map(|window| window[1].ele.unwrap() - window[0].ele.unwrap())
272                .collect(),
273        )
274    }
275}
276
277impl From<&TrackSegment> for WaypointPath {
278    fn from(segment: &TrackSegment) -> Self {
279        Self::from_slice(&segment.points)
280    }
281}
282
283impl From<&Route> for WaypointPath {
284    fn from(route: &Route) -> Self {
285        Self::from_slice(&route.points)
286    }
287}
288
289impl<'a> IntoIterator for &'a WaypointPath {
290    type Item = &'a Waypoint;
291    type IntoIter = std::slice::Iter<'a, Waypoint>;
292
293    fn into_iter(self) -> Self::IntoIter {
294        self.points.iter()
295    }
296}
297
298fn duration_between(
299    start: chrono::DateTime<chrono::Utc>,
300    end: chrono::DateTime<chrono::Utc>,
301) -> Option<Duration> {
302    let millis = (end - start).num_milliseconds();
303    if millis >= 0 {
304        Some(Duration::from_millis(millis as u64))
305    } else {
306        None
307    }
308}