Files
gpx.studio/gpx-rs/engine/src/core/statistics/statistics.rs
T

413 lines
14 KiB
Rust
Raw Normal View History

2026-09-20 13:58:27 +02:00
use crate::{
2026-10-03 10:03:22 +02:00
LngLat, LngLatBounds, TrackSegment, TrackSegmentIndex, Trackpoint, distance, for_each_window,
2026-10-03 16:16:47 +02:00
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
2026-09-20 13:58:27 +02:00
};
2026-09-21 23:49:57 +02:00
#[derive(Default, Debug)]
2026-10-02 18:50:10 +02:00
pub struct Statistics {
2026-09-24 20:55:30 +02:00
pub global: GlobalStatistics,
2026-09-20 13:58:27 +02:00
pub local: Vec<TrackpointStatistics>,
}
2026-10-02 18:50:10 +02:00
impl Statistics {
2026-09-21 23:49:57 +02:00
pub fn total_speed(&self) -> Option<f64> {
2026-09-30 19:19:23 +02:00
self.global.total_speed()
2026-09-21 23:49:57 +02:00
}
pub fn moving_speed(&self) -> Option<f64> {
2026-09-30 19:19:23 +02:00
self.global.moving_speed()
2026-09-21 23:49:57 +02:00
}
2026-09-23 19:53:39 +02:00
pub fn compute(trkseg: &TrackSegment) -> Self {
2026-09-21 23:49:57 +02:00
let mut stats = Self::default();
2026-09-23 19:53:39 +02:00
if trkseg.len() == 0 {
2026-09-21 23:49:57 +02:00
return stats;
}
2026-09-23 19:53:39 +02:00
let mut prev = &trkseg[0];
for cur in trkseg.iter() {
2026-09-21 23:49:57 +02:00
stats.accumulate(prev, cur);
prev = cur;
}
2026-09-23 19:53:39 +02:00
stats.compute_smoothed_speed(trkseg);
2026-10-02 18:50:10 +02:00
stats.compute_smoothed_elevation_segments(trkseg);
stats.compute_smoothed_slope(trkseg);
2026-09-21 23:49:57 +02:00
stats
}
2026-09-23 19:53:39 +02:00
fn accumulate(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
2026-09-20 13:58:27 +02:00
self.accumulate_distance_and_time(prev, cur);
self.update_time_bounds(cur.time);
2026-09-24 20:55:30 +02:00
self.update_bounds(cur.coordinates);
2026-09-20 13:58:27 +02:00
self.local
.push(TrackpointStatistics::from_partial_stats(&self));
}
2026-09-23 19:53:39 +02:00
fn accumulate_distance_and_time(&mut self, prev: &Trackpoint, cur: &Trackpoint) {
2026-09-21 23:49:57 +02:00
let dist = distance(prev.coordinates, cur.coordinates);
2026-10-02 18:50:10 +02:00
let time = time_diff(&cur.time, &prev.time);
2026-09-20 13:58:27 +02:00
2026-09-24 20:55:30 +02:00
self.global.total_distance += dist;
2026-09-20 13:58:27 +02:00
if let Some(time) = time {
let speed = speed(dist, time);
if speed >= 0.5 && speed <= 1500.0 {
2026-09-24 20:55:30 +02:00
self.global.moving_distance = self
.global
.moving_distance
.map_or(Some(dist), |d| Some(d + dist));
self.global.moving_time = self
.global
.moving_time
.map_or(Some(time), |t| Some(t + time));
2026-09-20 13:58:27 +02:00
}
}
}
fn update_time_bounds(&mut self, time: Option<i64>) {
if let Some(time) = time {
2026-09-24 20:55:30 +02:00
if self.global.start_time.is_none() {
self.global.start_time = Some(time);
2026-09-20 13:58:27 +02:00
}
2026-09-24 20:55:30 +02:00
self.global.end_time = Some(time);
2026-10-03 16:16:47 +02:00
self.global.total_time = time_diff(&self.global.end_time, &self.global.start_time);
2026-09-20 13:58:27 +02:00
}
}
2026-09-24 20:55:30 +02:00
fn update_bounds(&mut self, coordinates: LngLat) {
self.global.bounds.extend(coordinates);
2026-09-20 13:58:27 +02:00
}
2026-09-21 23:49:57 +02:00
2026-09-23 19:53:39 +02:00
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
2026-09-21 23:49:57 +02:00
for_each_window!(
2026-09-23 19:53:39 +02:00
trkseg,
trkseg.first_index(),
trkseg.last_index(),
2026-09-21 23:49:57 +02:00
Some(10000),
2026-10-02 18:50:10 +02:00
|i, j| time_diff(&trkseg[i].time, &trkseg[j].time),
2026-09-21 23:49:57 +02:00
|i, left, right| {
2026-10-02 18:50:10 +02:00
self.local[i.flat].speed =
time_diff(&trkseg[right].time, &trkseg[left].time).map(|t| {
speed(
self.local[right.flat].total_distance
- self.local[left.flat].total_distance,
t,
)
});
2026-09-21 23:49:57 +02:00
},
);
}
2026-10-02 18:50:10 +02:00
fn compute_smoothed_elevation_segments(&mut self, trkseg: &TrackSegment) {
let simplified = self.get_elevation_extremas(trkseg);
2026-09-21 23:49:57 +02:00
for i in 0..(simplified.len() - 1) {
let start = simplified[i];
let end = simplified[i + 1];
let last = i + 1 == simplified.len() - 1;
2026-10-02 18:50:10 +02:00
self.compute_smoothed_elevation_gain(trkseg, start, end, last);
2026-09-23 19:53:39 +02:00
let segment_dist =
2026-10-02 18:50:10 +02:00
self.local[end.flat].total_distance - self.local[start.flat].total_distance;
2026-09-23 19:53:39 +02:00
let segment_ele = trkseg[end].ele - trkseg[start].ele;
2026-09-21 23:49:57 +02:00
let segment_slope = slope(segment_ele, segment_dist);
2026-10-02 18:50:10 +02:00
for k in start.flat..(end.flat + last as usize) {
2026-09-21 23:49:57 +02:00
self.local[k].slope_segment = SlopeSegment {
slope: segment_slope,
distance: segment_dist,
};
}
}
2026-10-02 18:50:10 +02:00
}
2026-09-21 23:49:57 +02:00
2026-10-02 18:50:10 +02:00
fn compute_smoothed_elevation_gain(
&mut self,
trkseg: &TrackSegment,
start: TrackSegmentIndex,
end: TrackSegmentIndex,
last: bool,
) {
let mut cumul_ele = 0.0;
let mut current_left = start;
let mut current_right = Some(start);
let mut prev_smoothed_ele = trkseg[start].ele;
for_each_window!(
trkseg,
Some(start),
Some(end),
0.1,
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
|i, left, right| {
while current_left != left {
cumul_ele -= trkseg[current_left].ele;
current_left = trkseg.next_index(Some(current_left)).unwrap();
}
while let Some(current) = current_right {
if current > right {
break;
}
cumul_ele += trkseg[current].ele;
current_right = trkseg.next_index(current_right);
}
let smoothed_ele = if i == start || i == end {
trkseg[i].ele
} else {
cumul_ele / (right.flat - left.flat + 1) as f64
};
let delta = smoothed_ele - prev_smoothed_ele;
if delta > 0.0 {
self.global.elevation_gain += delta;
} else if delta < 0.0 {
self.global.elevation_loss -= delta;
}
if i < end || last {
self.local[i.flat].elevation_gain = self.global.elevation_gain;
self.local[i.flat].elevation_loss = self.global.elevation_loss;
}
prev_smoothed_ele = smoothed_ele;
},
);
}
fn compute_smoothed_slope(&mut self, trkseg: &TrackSegment) {
2026-09-21 23:49:57 +02:00
for_each_window!(
2026-09-23 19:53:39 +02:00
trkseg,
trkseg.first_index(),
trkseg.last_index(),
2026-09-21 23:49:57 +02:00
0.05,
2026-10-02 18:50:10 +02:00
|i, j| self.local[j.flat].total_distance - self.local[i.flat].total_distance,
2026-09-21 23:49:57 +02:00
|i, left, right| {
2026-09-23 19:53:39 +02:00
let dist =
2026-10-02 18:50:10 +02:00
self.local[right.flat].total_distance - self.local[left.flat].total_distance;
2026-09-23 19:53:39 +02:00
let ele = trkseg[right].ele - trkseg[left].ele;
2026-10-02 18:50:10 +02:00
self.local[i.flat].slope = slope(ele, dist);
2026-09-21 23:49:57 +02:00
},
);
}
2026-10-02 18:50:10 +02:00
fn get_elevation_extremas(&self, trkseg: &TrackSegment) -> Vec<TrackSegmentIndex> {
ramer_douglas_peucker(
trkseg,
&|idx| {
(
self.local[idx.flat].total_distance * 1000.0,
trkseg[idx].ele,
)
},
20.0,
)
}
2026-09-20 13:58:27 +02:00
}
2026-09-21 23:49:57 +02:00
#[derive(Default, Debug)]
2026-09-24 20:55:30 +02:00
pub struct GlobalStatistics {
pub total_distance: f64,
pub moving_distance: Option<f64>,
2026-10-03 16:16:47 +02:00
pub total_time: Option<i32>,
2026-10-02 18:50:10 +02:00
pub moving_time: Option<i32>,
2026-09-24 20:55:30 +02:00
pub elevation_gain: f64,
pub elevation_loss: f64,
pub start_time: Option<i64>,
pub end_time: Option<i64>,
pub bounds: LngLatBounds,
}
impl GlobalStatistics {
2026-09-30 19:19:23 +02:00
pub fn total_speed(&self) -> Option<f64> {
2026-10-03 16:16:47 +02:00
self.total_time.map(|t| speed(self.total_distance, t))
2026-09-30 19:19:23 +02:00
}
pub fn moving_speed(&self) -> Option<f64> {
self.moving_distance
.zip(self.moving_time)
.map(|(d, t)| speed(d, t))
}
2026-09-24 20:55:30 +02:00
pub fn merge(&mut self, other: &GlobalStatistics) {
self.total_distance += other.total_distance;
self.moving_distance = sum_options(self.moving_distance, other.moving_distance);
2026-10-03 16:16:47 +02:00
self.total_time = sum_options(self.total_time, other.total_time);
2026-09-24 20:55:30 +02:00
self.moving_time = sum_options(self.moving_time, other.moving_time);
self.elevation_gain += other.elevation_gain;
self.elevation_loss += other.elevation_loss;
2026-10-03 16:16:47 +02:00
self.start_time = min_options(self.start_time, other.start_time);
self.end_time = max_options(self.end_time, other.end_time);
2026-09-24 20:55:30 +02:00
self.bounds.merge(&other.bounds);
}
2026-09-20 13:58:27 +02:00
}
2026-09-21 23:49:57 +02:00
#[derive(Default, Debug)]
2026-09-20 13:58:27 +02:00
pub struct TrackpointStatistics {
pub total_distance: f64,
2026-09-21 23:49:57 +02:00
pub moving_distance: Option<f64>,
2026-10-02 18:50:10 +02:00
pub total_time: Option<i32>,
pub moving_time: Option<i32>,
2026-09-21 23:49:57 +02:00
pub speed: Option<f64>,
2026-09-20 13:58:27 +02:00
pub elevation_gain: f64,
pub elevation_loss: f64,
pub slope: f64,
pub slope_segment: SlopeSegment,
}
impl TrackpointStatistics {
2026-10-02 18:50:10 +02:00
fn from_partial_stats(stats: &Statistics) -> Self {
2026-09-20 13:58:27 +02:00
Self {
2026-09-24 20:55:30 +02:00
total_distance: stats.global.total_distance,
moving_distance: stats.global.moving_distance,
2026-10-03 16:16:47 +02:00
total_time: stats.global.total_time,
2026-09-24 20:55:30 +02:00
moving_time: stats.global.moving_time,
// stats below are computed later
2026-09-21 23:49:57 +02:00
speed: None,
2026-09-24 20:55:30 +02:00
elevation_gain: Default::default(),
elevation_loss: Default::default(),
2026-09-20 13:58:27 +02:00
slope: Default::default(),
slope_segment: Default::default(),
}
}
}
2026-09-24 20:55:30 +02:00
#[derive(Default, Debug)]
pub struct SlopeSegment {
pub slope: f64,
pub distance: f64,
}
2026-09-21 23:49:57 +02:00
#[cfg(test)]
mod tests {
use std::{fs::File, io::Read};
2026-09-20 13:58:27 +02:00
2026-10-03 10:03:22 +02:00
use crate::parse;
2026-09-21 23:49:57 +02:00
use super::*;
#[test]
2026-09-23 19:53:39 +02:00
fn test_compute_smoothed_speed() {
2026-09-21 23:49:57 +02:00
let mut f = File::open("data/with_time.gpx").unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
let gpx = parse(data.as_bytes()).unwrap();
2026-09-23 19:53:39 +02:00
let trkseg = &gpx.trk[0].trkseg[0];
2026-10-02 18:50:10 +02:00
let stats = Statistics::compute(trkseg);
2026-09-23 19:53:39 +02:00
assert_eq!(stats.local.len(), trkseg.len());
for trkpt_stats in stats.local.iter() {
assert!(trkpt_stats.speed.is_some());
let speed = trkpt_stats.speed.unwrap();
assert_ne!(speed, f64::NAN);
assert!((speed - 20.0).abs() < 0.1);
}
2026-09-20 13:58:27 +02:00
}
2026-10-01 18:42:31 +02:00
2026-10-03 16:16:47 +02:00
fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap();
let mut data = String::new();
let _ = f.read_to_string(&mut data);
parse(data.as_bytes()).unwrap()
}
#[test]
fn test_compute_empty_segment() {
let stats = Statistics::compute(&TrackSegment::default());
assert!(stats.local.is_empty());
assert_eq!(stats.global.total_distance, 0.0);
}
#[test]
fn test_compute_distance_and_bounds() {
let gpx = load("data/simple.gpx");
let trkseg = &gpx.trk[0].trkseg[0];
let stats = Statistics::compute(trkseg);
assert_eq!(stats.local.len(), trkseg.len());
assert_eq!(stats.local[0].total_distance, 0.0);
assert!(stats.global.total_distance > 0.0);
// cumulative distance never decreases and ends at the global distance
assert!(
stats
.local
.windows(2)
.all(|w| w[0].total_distance <= w[1].total_distance)
);
let last = stats.local.last().unwrap();
assert!((last.total_distance - stats.global.total_distance).abs() < 1e-9);
assert!(stats.global.elevation_gain >= 0.0);
assert!(stats.global.elevation_loss >= 0.0);
// every point lies inside the bounds
let b = &stats.global.bounds;
for trkpt in trkseg.iter() {
assert!(b.sw.lng <= trkpt.coordinates.lng && trkpt.coordinates.lng <= b.ne.lng);
assert!(b.sw.lat <= trkpt.coordinates.lat && trkpt.coordinates.lat <= b.ne.lat);
}
}
#[test]
fn test_compute_time() {
let gpx = load("data/with_time.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
let total_time = stats.global.total_time.unwrap();
assert!(total_time > 0);
let speed = stats.global.total_speed().unwrap();
assert!((speed - 20.0).abs() < 0.5, "{speed}");
}
#[test]
fn test_no_time_without_timestamps() {
let gpx = load("data/simple.gpx");
let stats = Statistics::compute(&gpx.trk[0].trkseg[0]);
assert!(stats.global.total_time.is_none());
assert!(stats.global.total_speed().is_none());
assert!(stats.local.iter().all(|s| s.speed.is_none()));
}
#[test]
fn test_global_merge() {
let mut a = GlobalStatistics::default();
a.total_distance = 1.0;
a.elevation_gain = 10.0;
a.moving_time = Some(5);
a.bounds.extend(crate::LngLat { lng: 0.0, lat: 0.0 });
let mut b = GlobalStatistics::default();
b.total_distance = 2.0;
b.elevation_loss = 4.0;
b.moving_distance = Some(1.5);
b.moving_time = Some(7);
b.bounds.extend(crate::LngLat { lng: 2.0, lat: 3.0 });
a.merge(&b);
assert_eq!(a.total_distance, 3.0);
assert_eq!(a.elevation_gain, 10.0);
assert_eq!(a.elevation_loss, 4.0);
assert_eq!(a.moving_distance, Some(1.5));
assert_eq!(a.moving_time, Some(12));
assert_eq!(a.total_time, None);
let mut c = GlobalStatistics::default();
c.start_time = Some(1_000);
c.end_time = Some(4_000);
c.total_time = Some(3_000);
let mut d = GlobalStatistics::default();
d.start_time = Some(10_000);
d.end_time = Some(12_000);
d.total_time = Some(2_000);
c.merge(&d);
// 3 s + 2 s, the gap between the two is ignored
assert_eq!(c.total_time, Some(5_000));
let mut cumul = GlobalStatistics::default();
cumul.merge(&c);
cumul.merge(&d);
assert_eq!(cumul.start_time, Some(1_000));
assert_eq!(cumul.end_time, Some(12_000));
assert_eq!(cumul.total_time, Some(7_000));
assert_eq!((a.bounds.ne.lng, a.bounds.ne.lat), (2.0, 3.0));
}
2026-09-20 13:58:27 +02:00
}