use crate::area::Area;
use crate::climbs::{detect_climbs, ClimbStats};
use crate::elevation::{
compute_gain_loss, compute_slopes, cumulative_horizontal_distances, ELEV_MEDIAN_RADIUS_KM,
ELEV_NOISE_THRESHOLD_M,
};
use crate::extrema::{find_peaks, find_valleys};
use crate::simplify::douglas_peucker_indices;
use crate::{Elevation, Location, TraceError};
const DP_EPSILON_KM: f64 = 0.015;
const DP_THRESHOLD: usize = 1000;
const LOCK_IN_KM: f64 = 1.0;
const EARLY_STOP_KM: f64 = 2.0;
#[derive(Debug, PartialEq)]
pub struct Trace {
pub locations: Vec<Location>,
pub cumulative_distances: Vec<f64>,
pub cumulative_elevation_gains: Vec<f64>,
pub cumulative_elevation_losses: Vec<f64>,
pub slopes: Vec<f64>,
pub peaks: Vec<usize>,
pub valleys: Vec<usize>,
pub climbs: Vec<ClimbStats>,
pub total_distance: f64,
pub total_elevation_gain: f64,
pub total_elevation_loss: f64,
}
impl Trace {
pub fn new(raw: &[Location]) -> Result<Self, TraceError> {
if raw.is_empty() {
return Err(TraceError::EmptyTrace);
}
let n = raw.len();
let (locations, src_indices): (Vec<Location>, Vec<usize>) = if n > DP_THRESHOLD {
let indices = douglas_peucker_indices(raw, DP_EPSILON_KM);
let simplified = indices.iter().map(|&i| raw[i]).collect();
(simplified, indices)
} else {
(raw.to_vec(), (0..n).collect())
};
let cumulative_distances = cumulative_horizontal_distances(&locations);
let total_distance = *cumulative_distances
.last()
.expect("locations is non-empty, so cumulative_distances has at least one element");
let gain_loss = compute_gain_loss(raw, ELEV_MEDIAN_RADIUS_KM, ELEV_NOISE_THRESHOLD_M);
let cumulative_elevation_gains: Vec<f64> = src_indices
.iter()
.map(|&src| gain_loss.cum_gain[src])
.collect();
let cumulative_elevation_losses: Vec<f64> = src_indices
.iter()
.map(|&src| gain_loss.cum_loss[src])
.collect();
let slopes = compute_slopes(&locations, &cumulative_distances);
let elevations: Vec<f32> = locations.iter().map(|l| l.altitude as f32).collect();
let peaks = if locations.len() >= 3 {
find_peaks(&elevations)
} else {
vec![]
};
let valleys = if locations.len() >= 3 {
find_valleys(&elevations)
} else {
vec![]
};
let climbs = detect_climbs(&peaks, &valleys, &locations, &cumulative_distances);
Ok(Trace {
locations,
cumulative_distances,
cumulative_elevation_gains,
cumulative_elevation_losses,
slopes,
peaks,
valleys,
climbs,
total_distance,
total_elevation_gain: gain_loss.total_gain,
total_elevation_loss: gain_loss.total_loss,
})
}
pub fn index_at_distance(&self, dist_km: f64) -> usize {
let pos = self.cumulative_distances.partition_point(|&d| d < dist_km);
pos.min(self.locations.len() - 1)
}
pub fn point_at_distance(&self, dist_km: f64) -> Option<&Location> {
if dist_km < 0.0 {
return None;
}
self.locations.get(self.index_at_distance(dist_km))
}
pub fn slice_between_distances(&self, start_km: f64, end_km: f64) -> Option<&[Location]> {
if start_km < 0.0 || end_km < 0.0 || start_km > end_km {
return None;
}
let start_idx = self.index_at_distance(start_km);
let end_idx = self.index_at_distance(end_km);
if start_idx <= end_idx && end_idx < self.locations.len() {
Some(&self.locations[start_idx..=end_idx])
} else {
None
}
}
pub fn find_closest_point(&self, target: &Location) -> Option<(&Location, usize, f64)> {
self.find_closest_point_from(target, 0)
}
pub fn find_closest_point_from(
&self,
target: &Location,
start_from: usize,
) -> Option<(&Location, usize, f64)> {
if start_from >= self.locations.len() {
return None;
}
let mut best_dist = f64::INFINITY;
let mut best_idx = start_from;
for (offset, loc) in self.locations[start_from..].iter().enumerate() {
let d = target.calculate_distance_to(loc);
if d < best_dist {
best_dist = d;
best_idx = start_from + offset;
} else if best_dist < LOCK_IN_KM && d > best_dist + EARLY_STOP_KM {
break;
}
}
Some((&self.locations[best_idx], best_idx, best_dist))
}
pub fn length(&self) -> f64 {
self.total_distance
}
pub fn area(&self) -> Area {
let first = self
.locations
.first()
.expect("Trace is never empty by construction");
self.locations.iter().fold(
Area {
min_longitude: first.longitude,
max_longitude: first.longitude,
min_latitude: first.latitude,
max_latitude: first.latitude,
},
|acc, loc| Area {
min_latitude: acc.min_latitude.min(loc.latitude),
max_latitude: acc.max_latitude.max(loc.latitude),
min_longitude: acc.min_longitude.min(loc.longitude),
max_longitude: acc.max_longitude.max(loc.longitude),
},
)
}
pub fn elevation(&self) -> Elevation {
self.locations
.windows(2)
.map(|w| w[1].altitude - w[0].altitude)
.fold(
Elevation {
positive: 0.0,
negative: 0.0,
},
|mut acc, delta| {
if delta > 0.0 {
acc.positive += delta;
} else {
acc.negative += delta.abs();
}
acc
},
)
}
pub fn get_section(
&self,
start_index: usize,
end_index: usize,
) -> Result<Vec<Location>, TraceError> {
if start_index >= end_index {
return Err(TraceError::InvalidRange {
start_index,
end_index,
});
}
if end_index >= self.locations.len() {
return Err(TraceError::IndexOutOfBounds {
index: end_index,
len: self.locations.len(),
});
}
Ok(self.locations[start_index..=end_index].to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helper;
#[test]
fn empty_input_is_rejected() {
assert_eq!(Trace::new(&[]), Err(TraceError::EmptyTrace));
}
#[test]
fn single_location_trace() {
let loc = Location {
longitude: 0.0,
latitude: 0.0,
altitude: 100.0,
};
let trace = Trace::new(&[loc]).unwrap();
assert_eq!(trace.locations.len(), 1);
assert_eq!(trace.total_distance, 0.0);
}
#[test]
fn two_location_trace_has_positive_distance() {
let paris = Location {
longitude: 2.350987,
latitude: 48.856667,
altitude: 0.0,
};
let moscow = Location {
longitude: 37.617634,
latitude: 55.755787,
altitude: 0.0,
};
let trace = Trace::new(&[paris, moscow]).unwrap();
let len = trace.length();
assert!((len - 2486.340992526076).abs() < 1e-6);
}
#[test]
fn full_dataset_builds_successfully() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert!(!trace.locations.is_empty());
assert!(trace.total_distance > 0.0);
assert_eq!(trace.cumulative_distances.len(), trace.locations.len());
assert_eq!(
trace.cumulative_elevation_gains.len(),
trace.locations.len()
);
assert_eq!(
trace.cumulative_elevation_losses.len(),
trace.locations.len()
);
assert_eq!(trace.slopes.len(), trace.locations.len());
}
#[test]
fn cumulative_distances_start_zero_and_non_decreasing() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert_eq!(trace.cumulative_distances[0], 0.0);
for i in 1..trace.cumulative_distances.len() {
assert!(trace.cumulative_distances[i] >= trace.cumulative_distances[i - 1]);
}
}
#[test]
fn total_elevation_gain_and_loss_non_negative() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert!(trace.total_elevation_gain >= 0.0);
assert!(trace.total_elevation_loss >= 0.0);
}
#[test]
fn point_at_distance_negative_returns_none() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert!(trace.point_at_distance(-1.0).is_none());
}
#[test]
fn point_at_distance_zero_returns_first() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let pt = trace.point_at_distance(0.0).unwrap();
assert_eq!(pt, &trace.locations[0]);
}
#[test]
fn point_at_distance_beyond_end_returns_last() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let pt = trace.point_at_distance(1_000_000.0).unwrap();
assert_eq!(pt, trace.locations.last().unwrap());
}
#[test]
fn slice_full_range_covers_all_locations() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let slice = trace
.slice_between_distances(0.0, trace.total_distance)
.unwrap();
assert_eq!(slice.len(), trace.locations.len());
}
#[test]
fn slice_invalid_range_returns_none() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert!(trace.slice_between_distances(10.0, 5.0).is_none());
assert!(trace.slice_between_distances(-1.0, 5.0).is_none());
}
#[test]
fn index_at_distance_zero_is_zero() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert_eq!(trace.index_at_distance(0.0), 0);
}
#[test]
fn index_at_distance_end_is_last() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let last = trace.locations.len() - 1;
assert_eq!(trace.index_at_distance(trace.total_distance), last);
}
#[test]
fn find_closest_point_exact_match() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let target = &trace.locations[0];
let (_, idx, dist) = trace.find_closest_point(target).unwrap();
assert_eq!(idx, 0);
assert!(dist < 1e-9);
}
#[test]
fn find_closest_point_between_paris_and_moscow() {
let paris = Location {
longitude: 2.350987,
latitude: 48.856667,
altitude: 0.0,
};
let moscow = Location {
longitude: 37.617634,
latitude: 55.755787,
altitude: 0.0,
};
let trace = Trace::new(&[paris, moscow]).unwrap();
let near_paris = Location {
longitude: 1.350987,
latitude: 49.856667,
altitude: 0.0,
};
let (loc, _, _) = trace.find_closest_point(&near_paris).unwrap();
assert_eq!(loc, &paris);
}
#[test]
fn area_paris_to_moscow() {
let paris = Location {
longitude: 2.350987,
latitude: 48.856667,
altitude: 0.0,
};
let moscow = Location {
longitude: 37.617634,
latitude: 55.755787,
altitude: 0.0,
};
let trace = Trace::new(&[paris, moscow]).unwrap();
let area = trace.area();
assert_eq!(area.min_longitude, 2.350987);
assert_eq!(area.max_longitude, 37.617634);
assert_eq!(area.min_latitude, 48.856667);
assert_eq!(area.max_latitude, 55.755787);
}
#[test]
fn elevation_sums_gains_and_losses() {
let locations = [
Location {
longitude: 0.0,
latitude: 0.0,
altitude: 100.0,
},
Location {
longitude: 0.0,
latitude: 0.001,
altitude: 150.0,
},
Location {
longitude: 0.0,
latitude: 0.002,
altitude: 120.0,
},
];
let trace = Trace::new(&locations).unwrap();
let elevation = trace.elevation();
assert_eq!(elevation.positive, 50.0);
assert_eq!(elevation.negative, 30.0);
}
#[test]
fn get_section_returns_sub_slice() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let section = trace.get_section(1, 3).unwrap();
assert_eq!(section, trace.locations[1..=3].to_vec());
}
#[test]
fn get_section_start_not_smaller_than_end_errs() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
assert_eq!(
trace.get_section(2, 2),
Err(TraceError::InvalidRange {
start_index: 2,
end_index: 2
})
);
assert_eq!(
trace.get_section(3, 1),
Err(TraceError::InvalidRange {
start_index: 3,
end_index: 1
})
);
}
#[test]
fn get_section_out_of_bounds_errs() {
let locations = helper::get_locations();
let trace = Trace::new(&locations).unwrap();
let len = trace.locations.len();
assert_eq!(
trace.get_section(0, len),
Err(TraceError::IndexOutOfBounds { index: len, len })
);
}
}