use crate::Location;
const MIN_CLIMB_SCORE: f64 = 3.5; const MIN_CLIMB_DIST_KM: f64 = 0.5;
const MIN_AVG_GRADIENT: f64 = 3.0;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "wasm", derive(serde::Serialize))]
pub struct ClimbStats {
pub start_index: usize,
pub end_index: usize,
pub start_dist_km: f64,
pub climb_dist_km: f64,
pub elevation_gain: f64,
pub summit_elev: f64,
pub avg_gradient: f64,
}
fn qualifies_as_climb(dist_km: f64, avg_gradient: f64) -> bool {
dist_km >= MIN_CLIMB_DIST_KM
&& avg_gradient >= MIN_AVG_GRADIENT
&& dist_km * avg_gradient > MIN_CLIMB_SCORE
}
pub fn detect_climbs(
peaks: &[usize],
valleys: &[usize],
locations: &[Location],
cum_distances: &[f64],
) -> Vec<ClimbStats> {
if peaks.is_empty() || locations.is_empty() || cum_distances.is_empty() {
return vec![];
}
debug_assert_eq!(locations.len(), cum_distances.len());
let mut climbs = Vec::new();
let mut valley_cursor = 0usize;
let mut min_valley_start = 0usize;
for &peak_idx in peaks {
debug_assert!(peak_idx < locations.len());
while valley_cursor + 1 < valleys.len() && valleys[valley_cursor + 1] < peak_idx {
valley_cursor += 1;
}
let ampd_valley_idx = if !valleys.is_empty() && valleys[valley_cursor] < peak_idx {
valleys[valley_cursor]
} else {
0
};
let valley_idx = if ampd_valley_idx < min_valley_start {
(min_valley_start..peak_idx)
.min_by(|&a, &b| {
locations[a]
.altitude
.partial_cmp(&locations[b].altitude)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(min_valley_start)
} else {
ampd_valley_idx
};
if valley_idx >= peak_idx {
continue;
}
let valley_elev = locations[valley_idx].altitude;
let start_dist = cum_distances[valley_idx];
let end_dist = cum_distances[peak_idx];
let climb_dist = if end_dist > start_dist {
end_dist - start_dist
} else {
0.0
};
let summit_elev = locations[peak_idx].altitude;
let elev_gain = if summit_elev > valley_elev {
summit_elev - valley_elev
} else {
0.0
};
let avg_gradient = if climb_dist > 0.0 {
(elev_gain / (climb_dist * 1000.0)) * 100.0
} else {
0.0
};
if !qualifies_as_climb(climb_dist, avg_gradient) {
continue;
}
climbs.push(ClimbStats {
start_index: valley_idx,
end_index: peak_idx,
start_dist_km: start_dist,
climb_dist_km: climb_dist,
elevation_gain: elev_gain,
summit_elev,
avg_gradient,
});
min_valley_start = peak_idx;
}
climbs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Location;
fn loc(lat: f64, alt: f64) -> Location {
Location {
latitude: lat,
longitude: 0.0,
altitude: alt,
}
}
#[test]
fn empty_inputs_return_no_climbs() {
let climbs = detect_climbs(&[], &[], &[], &[]);
assert!(climbs.is_empty());
let pts = vec![loc(0.0, 100.0), loc(0.1, 200.0)];
let dists = vec![0.0, 1.0];
let climbs = detect_climbs(&[], &[], &pts, &dists);
assert!(climbs.is_empty());
}
#[test]
fn single_qualifying_climb() {
let pts = vec![
loc(0.0, 100.0),
loc(0.1, 200.0),
loc(0.2, 350.0),
loc(0.3, 450.0),
loc(0.4, 500.0),
];
let dists = vec![0.0, 1.0, 2.0, 3.0, 4.0]; let peaks = vec![4usize];
let valleys = vec![0usize];
let climbs = detect_climbs(&peaks, &valleys, &pts, &dists);
assert_eq!(climbs.len(), 1);
let c = &climbs[0];
assert_eq!(c.start_index, 0);
assert_eq!(c.end_index, 4);
assert!((c.climb_dist_km - 4.0).abs() < 1e-9);
assert!((c.elevation_gain - 400.0).abs() < 1e-9);
assert!((c.summit_elev - 500.0).abs() < 1e-9);
assert!((c.avg_gradient - 10.0).abs() < 1e-6); }
#[test]
fn two_peaks_no_ampd_valley_between_them_uses_fallback_minimum() {
let pts = vec![
loc(0.0, 100.0), loc(0.1, 200.0), loc(0.2, 600.0), loc(0.3, 580.0), loc(0.4, 700.0), ];
let dists = vec![0.0, 0.5, 2.0, 2.5, 4.0];
let peaks = vec![2usize, 4];
let valleys = vec![0usize];
let climbs = detect_climbs(&peaks, &valleys, &pts, &dists);
assert_eq!(climbs.len(), 2);
assert_eq!(climbs[0].start_index, 0);
assert_eq!(climbs[0].end_index, 2);
assert_eq!(climbs[1].start_index, 3);
assert_eq!(climbs[1].end_index, 4);
}
#[test]
fn garmin_qualification_filter() {
let pts = vec![
loc(0.0, 0.0), loc(0.1, 20.0), loc(0.2, 0.0), loc(0.3, 10.0), loc(0.4, 0.0), loc(0.5, 15.0), loc(0.6, 0.0), loc(0.7, 30.0), loc(0.8, 0.0), loc(0.9, 35.0), loc(1.0, 0.0), loc(1.1, 48.0), ];
let dists = vec![0.0, 0.4, 0.8, 1.4, 1.8, 2.3, 2.7, 3.3, 3.7, 4.4, 4.8, 5.6];
let peaks = vec![1, 3, 5, 7, 9, 11];
let valleys = vec![0, 2, 4, 6, 8, 10];
let climbs = detect_climbs(&peaks, &valleys, &pts, &dists);
assert_eq!(climbs.len(), 1, "expected only peak F to qualify");
assert_eq!(climbs[0].start_index, 10);
assert_eq!(climbs[0].end_index, 11);
assert!((climbs[0].climb_dist_km - 0.8).abs() < 1e-9);
assert!((climbs[0].avg_gradient - 6.0).abs() < 1e-3);
}
}