speed fix

This commit is contained in:
vcoppe committed 2026-10-07 09:35:27 +02:00
1 parent adbe4a2693
commit 29850c1c8f
4 files changed
+210 -61

No files matched your search

+115 -10
View File
@@ -3,6 +3,20 @@ use crate::{
max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff, max_options, min_options, ramer_douglas_peucker, slope, speed, sum_options, time_diff,
}; };
/// Time between two trackpoints, to size the windows of a smoothing. It is unknown when one of
/// them has no time, which is longer than any known time span: the window does not go there.
#[derive(Clone, Copy, PartialEq, PartialOrd)]
enum TimeSpan {
Known(i64),
Unknown,
}
impl TimeSpan {
fn between(end: Option<i64>, start: Option<i64>) -> Self {
time_diff(end, start).map_or(Self::Unknown, Self::Known)
}
}
#[derive(Default, Debug)] #[derive(Default, Debug)]
pub struct Statistics { pub struct Statistics {
pub global: GlobalStatistics, pub global: GlobalStatistics,
@@ -84,22 +98,36 @@ impl Statistics {
self.global.bounds.extend(coordinates); self.global.bounds.extend(coordinates);
} }
/// Speed over a window of 10 seconds on each side of the trackpoints that have a time (the
/// others have no speed). The windows do not extend to the trackpoints without time: if the
/// previous or next trackpoint has none, the window ends at the trackpoint itself on that side.
fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) { fn compute_smoothed_speed(&mut self, trkseg: &TrackSegment) {
for_each_window!( for_each_window!(
trkseg, trkseg,
trkseg.first_index(), trkseg.first_index(),
trkseg.last_index(), trkseg.last_index(),
Some(10_000), TimeSpan::Known(10_000),
|i, j| time_diff(trkseg[j].time, trkseg[i].time), |i, j| TimeSpan::between(trkseg[j].time, trkseg[i].time),
|i, left, right| { |i, left, right| {
self.local[i.flat].speed = if trkseg[i].time.is_some() {
time_diff(trkseg[right].time, trkseg[left].time).map(|t| { let timed = |index: TrackSegmentIndex| {
speed( if trkseg[index].time.is_some() {
self.local[right.flat].total_distance index
- self.local[left.flat].total_distance, } else {
t, i
) }
}); };
let (left, right) = (timed(left), timed(right));
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
.filter(|time| *time > 0)
.map(|time| {
speed(
self.local[right.flat].total_distance
- self.local[left.flat].total_distance,
time,
)
});
}
}, },
); );
} }
@@ -344,6 +372,83 @@ mod tests {
} }
} }
/// The speeds of the segment of `data/with_time.gpx` (80 trackpoints at 20 km/h) once the
/// trackpoints at `holes` have lost their time.
fn speeds_with_holes(holes: &[usize]) -> Vec<Option<f64>> {
let gpx = load("data/with_time.gpx");
let mut segment = gpx.trk[0].trkseg[0].clone();
let points: Vec<_> = segment
.iter()
.enumerate()
.map(|(i, trkpt)| {
let mut trkpt = trkpt.clone();
if holes.contains(&i) {
trkpt.time = None;
}
trkpt
})
.collect();
segment.splice(0, points.len(), points);
Statistics::compute(&segment)
.local
.iter()
.map(|local| local.speed)
.collect()
}
/// Without time, there is no speed. With one, the speed is the one of the trackpoints around.
fn assert_speeds_with_holes(holes: &[usize]) {
let speeds = speeds_with_holes(holes);
assert_eq!(speeds.len(), 80);
for (i, speed) in speeds.iter().enumerate() {
if holes.contains(&i) {
assert_eq!(*speed, None, "trackpoint {i}");
} else {
let speed = speed.unwrap_or_else(|| panic!("no speed for trackpoint {i}"));
assert!((speed - 20.0).abs() < 0.1, "trackpoint {i}: {speed}");
}
}
}
#[test]
fn test_speed_without_holes() {
assert_speeds_with_holes(&[]);
}
#[test]
fn test_speed_with_holes_at_the_beginning() {
assert_speeds_with_holes(&[0, 1, 2, 3, 4]);
}
#[test]
fn test_speed_with_holes_at_the_end() {
assert_speeds_with_holes(&[75, 76, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_at_both_ends() {
assert_speeds_with_holes(&[0, 1, 2, 77, 78, 79]);
}
#[test]
fn test_speed_with_holes_in_the_middle() {
assert_speeds_with_holes(&[40]);
assert_speeds_with_holes(&[30, 31, 32, 33, 34]);
assert_speeds_with_holes(&(20..60).collect::<Vec<_>>());
}
#[test]
fn test_speed_with_scattered_holes() {
let holes: Vec<_> = (0..80).step_by(7).collect();
assert_speeds_with_holes(&holes);
}
#[test]
fn test_speed_without_any_time() {
let holes: Vec<_> = (0..80).collect();
assert!(speeds_with_holes(&holes).iter().all(Option::is_none));
}
fn load(path: &str) -> crate::File { fn load(path: &str) -> crate::File {
let mut f = File::open(path).unwrap(); let mut f = File::open(path).unwrap();
let mut data = String::new(); let mut data = String::new();
@@ -11,17 +11,19 @@ pub const NO_TIME: i64 = i64::MIN;
/// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are /// Times are in milliseconds, distances in kilometers, speeds in km/h. Missing times are
/// [`NO_TIME`], missing measures are NaN. /// [`NO_TIME`], missing measures are NaN.
/// ///
/// The optional values (`time`, `hr`, `cad`, `atemp`, `power`) are `None` when no trackpoint of the /// The optional values (`moving_distance`, `total_time`, `moving_time`, `speed`, `time`, `hr`,
/// selection has one, and the OSM attributes are stored as [`Intervals`]. /// `cad`, `atemp`, `power`) are `None` when no trackpoint of the selection has one, and the OSM
/// attributes are stored as [`Intervals`]. The cumulative ones can only be missing before the
/// first trackpoint that has them, where they are 0.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct StatisticsBuffer { pub struct StatisticsBuffer {
/// Statistics of the whole selection. /// Statistics of the whole selection.
pub global: GlobalStatistics, pub global: GlobalStatistics,
pub total_distance: Vec<f64>, pub total_distance: Vec<f64>,
pub moving_distance: Vec<f64>, pub moving_distance: Option<Vec<f64>>,
pub total_time: Vec<i64>, pub total_time: Option<Vec<i64>>,
pub moving_time: Vec<i64>, pub moving_time: Option<Vec<i64>>,
pub speed: Vec<f64>, pub speed: Option<Vec<f64>>,
pub elevation_gain: Vec<f64>, pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>, pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>, pub slope: Vec<f64>,
@@ -108,10 +110,10 @@ impl StatisticsBuffer {
pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) { pub fn update(&mut self, selected: &[(&TrackSegment, &Statistics)]) {
self.global = GlobalStatistics::default(); self.global = GlobalStatistics::default();
self.total_distance.clear(); self.total_distance.clear();
self.moving_distance.clear(); self.moving_distance = None;
self.total_time.clear(); self.total_time = None;
self.moving_time.clear(); self.moving_time = None;
self.speed.clear(); self.speed = None;
self.elevation_gain.clear(); self.elevation_gain.clear();
self.elevation_loss.clear(); self.elevation_loss.clear();
self.slope.clear(); self.slope.clear();
@@ -136,18 +138,25 @@ impl StatisticsBuffer {
let cumul_stats = &self.global; let cumul_stats = &self.global;
self.total_distance self.total_distance
.push(cumul_stats.total_distance + trkpt_stats.total_distance); .push(cumul_stats.total_distance + trkpt_stats.total_distance);
self.moving_distance.push( push_optional(
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance) &mut self.moving_distance,
.unwrap_or_default(), index,
sum_options(cumul_stats.moving_distance, trkpt_stats.moving_distance),
0.0,
); );
self.total_time.push( push_optional(
sum_options(cumul_stats.total_time, trkpt_stats.total_time).unwrap_or_default(), &mut self.total_time,
index,
sum_options(cumul_stats.total_time, trkpt_stats.total_time),
0,
); );
self.moving_time.push( push_optional(
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time) &mut self.moving_time,
.unwrap_or_default(), index,
sum_options(cumul_stats.moving_time, trkpt_stats.moving_time),
0,
); );
self.speed.push(trkpt_stats.speed.unwrap_or_default()); push_optional(&mut self.speed, index, trkpt_stats.speed, f64::NAN);
self.elevation_gain self.elevation_gain
.push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain); .push(cumul_stats.elevation_gain + trkpt_stats.elevation_gain);
self.elevation_loss self.elevation_loss
@@ -193,15 +202,9 @@ impl StatisticsBuffer {
}; };
Some(GlobalStatistics { Some(GlobalStatistics {
total_distance: delta(&self.total_distance), total_distance: delta(&self.total_distance),
moving_distance: self moving_distance: self.moving_distance.as_deref().map(delta),
.global total_time: self.total_time.as_deref().map(delta_time),
.moving_distance moving_time: self.moving_time.as_deref().map(delta_time),
.map(|_| delta(&self.moving_distance)),
total_time: self.global.total_time.map(|_| delta_time(&self.total_time)),
moving_time: self
.global
.moving_time
.map(|_| delta_time(&self.moving_time)),
elevation_gain: delta(&self.elevation_gain), elevation_gain: delta(&self.elevation_gain),
elevation_loss: delta(&self.elevation_loss), elevation_loss: delta(&self.elevation_loss),
start_time: time(start), start_time: time(start),
@@ -243,10 +246,6 @@ mod tests {
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> { fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![ vec![
buffer.total_distance.len(), buffer.total_distance.len(),
buffer.moving_distance.len(),
buffer.total_time.len(),
buffer.moving_time.len(),
buffer.speed.len(),
buffer.elevation_gain.len(), buffer.elevation_gain.len(),
buffer.elevation_loss.len(), buffer.elevation_loss.len(),
buffer.slope.len(), buffer.slope.len(),
@@ -263,7 +262,7 @@ mod tests {
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[]); buffer.update(&[]);
assert!(buffer.is_empty()); assert!(buffer.is_empty());
assert!(buffer.speed.is_empty()); assert!(buffer.speed.is_none());
assert_eq!(buffer.global.total_distance, 0.0); assert_eq!(buffer.global.total_distance, 0.0);
assert!(buffer.slice(0, 0).is_none()); assert!(buffer.slice(0, 0).is_none());
} }
@@ -335,10 +334,11 @@ mod tests {
let mut buffer = StatisticsBuffer::default(); let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s), (&segment, &s)]); buffer.update(&[(&segment, &s), (&segment, &s)]);
assert_eq!(buffer.total_time[n - 1], duration); let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(buffer.total_time[n], duration); assert_eq!(total_time[n - 1], duration);
assert_eq!(buffer.total_time[2 * n - 1], 2 * duration); assert_eq!(total_time[n], duration);
assert!(buffer.total_time.windows(2).all(|w| w[0] <= w[1])); assert_eq!(total_time[2 * n - 1], 2 * duration);
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME)); assert!(buffer.time.unwrap().iter().all(|t| *t != NO_TIME));
} }
@@ -356,6 +356,11 @@ mod tests {
assert_eq!(buffer.global.hr.count, 0); assert_eq!(buffer.global.hr.count, 0);
assert_eq!(buffer.global.total_time, None); assert_eq!(buffer.global.total_time, None);
assert_eq!(buffer.len(), s.local.len()); assert_eq!(buffer.len(), s.local.len());
// they need times
assert!(buffer.speed.is_none());
assert!(buffer.moving_distance.is_none());
assert!(buffer.total_time.is_none());
assert!(buffer.moving_time.is_none());
// the slices do not need them // the slices do not need them
let slice = buffer.slice(0, 2).unwrap(); let slice = buffer.slice(0, 2).unwrap();
assert_eq!((slice.start_time, slice.end_time), (None, None)); assert_eq!((slice.start_time, slice.end_time), (None, None));
@@ -402,6 +407,29 @@ mod tests {
assert!(buffer.cad.is_none()); assert!(buffer.cad.is_none());
} }
#[test]
fn test_cumulative_values_are_zero_before_the_first_one() {
let (mut segment, _) = computed("data/with_time.gpx");
let n = segment.len();
let mut first = segment[0].clone();
first.time = None;
segment.splice(0, 1, vec![first]);
let s = Statistics::compute(&segment);
let mut buffer = StatisticsBuffer::default();
buffer.update(&[(&segment, &s)]);
let total_time = buffer.total_time.as_ref().unwrap();
assert_eq!(total_time.len(), n);
assert_eq!(total_time[0], 0);
assert_eq!(total_time[n - 1], s.global.total_time.unwrap());
assert!(total_time.windows(2).all(|w| w[0] <= w[1]));
// so a slice that starts on the first trackpoint is right
assert_eq!(
buffer.slice(0, n - 1).unwrap().total_time,
s.global.total_time
);
}
#[test] #[test]
fn test_surface_and_highway_intervals() { fn test_surface_and_highway_intervals() {
let (segment, s) = computed("data/with_highway.gpx"); let (segment, s) = computed("data/with_highway.gpx");
@@ -547,11 +575,17 @@ mod tests {
); );
assert_eq!( assert_eq!(
slice.total_time, slice.total_time,
Some(buffer.total_time[end] - buffer.total_time[start]) Some(
buffer.total_time.as_ref().unwrap()[end]
- buffer.total_time.as_ref().unwrap()[start]
)
); );
assert_eq!( assert_eq!(
slice.moving_time, slice.moving_time,
Some(buffer.moving_time[end] - buffer.moving_time[start]) Some(
buffer.moving_time.as_ref().unwrap()[end]
- buffer.moving_time.as_ref().unwrap()[start]
)
); );
assert_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start])); assert_eq!(slice.start_time, Some(buffer.time.as_ref().unwrap()[start]));
assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end])); assert_eq!(slice.end_time, Some(buffer.time.as_ref().unwrap()[end]));
+7 -6
View File
@@ -130,11 +130,6 @@ macro_rules! stats_getter {
} }
stats_getter!(total_distance, Float64Array); stats_getter!(total_distance, Float64Array);
stats_getter!(moving_distance, Float64Array);
// Durations since the start of the selection, in milliseconds.
stats_getter!(total_time, BigInt64Array);
stats_getter!(moving_time, BigInt64Array);
stats_getter!(speed, Float64Array);
stats_getter!(elevation_gain, Float64Array); stats_getter!(elevation_gain, Float64Array);
stats_getter!(elevation_loss, Float64Array); stats_getter!(elevation_loss, Float64Array);
stats_getter!(slope, Float64Array); stats_getter!(slope, Float64Array);
@@ -145,7 +140,8 @@ stats_getter!(lat, Float64Array);
stats_getter!(ele, Float64Array); stats_getter!(ele, Float64Array);
// The optional values are `undefined` when no trackpoint of the selection has one, else they have // The optional values are `undefined` when no trackpoint of the selection has one, else they have
// an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps). // an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the
// first trackpoint that has them for the cumulative distances and durations).
macro_rules! optional_stats_getter { macro_rules! optional_stats_getter {
($name:ident, $field:ident, $array:ident) => { ($name:ident, $field:ident, $array:ident) => {
#[wasm_bindgen] #[wasm_bindgen]
@@ -155,6 +151,11 @@ macro_rules! optional_stats_getter {
}; };
} }
optional_stats_getter!(moving_distance, moving_distance, Float64Array);
// Durations since the start of the selection, in milliseconds.
optional_stats_getter!(total_time, total_time, BigInt64Array);
optional_stats_getter!(moving_time, moving_time, BigInt64Array);
optional_stats_getter!(speed, speed, Float64Array);
// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). // Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing).
optional_stats_getter!(timestamps, time, BigInt64Array); optional_stats_getter!(timestamps, time, BigInt64Array);
@@ -39,6 +39,11 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family = Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
function optional(value: number, convert: (value: number) => number = (v) => v) {
return Number.isNaN(value) ? undefined : convert(value);
}
interface ElevationProfilePoint { interface ElevationProfilePoint {
x: number; x: number;
y: number; y: number;
@@ -440,7 +445,7 @@ export class ElevationProfile {
const timestamp = data.timestamps?.[index] ?? NO_TIME; const timestamp = data.timestamps?.[index] ?? NO_TIME;
datasets[0].push({ datasets[0].push({
x, x,
y: ele ? getConvertedElevation(ele, units.distance) : 0, y: getConvertedElevation(ele, units.distance),
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)), time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
slope: { slope: {
at: data.slope[index], at: data.slope[index],
@@ -457,25 +462,29 @@ export class ElevationProfile {
if (data.speed && (global.totalTime ?? 0) > 0) { if (data.speed && (global.totalTime ?? 0) > 0) {
datasets[1].push({ datasets[1].push({
x, x,
y: getConvertedVelocity(data.speed[index], units.velocity, units.distance), y: optional(data.speed[index], (value) =>
getConvertedVelocity(value, units.velocity, units.distance)
),
index, index,
}); });
} }
if (data.hr) { if (data.hr) {
datasets[2].push({ x, y: data.hr[index], index }); datasets[2].push({ x, y: optional(data.hr[index]), index });
} }
if (data.cad) { if (data.cad) {
datasets[3].push({ x, y: data.cad[index], index }); datasets[3].push({ x, y: optional(data.cad[index]), index });
} }
if (data.atemp) { if (data.atemp) {
datasets[4].push({ datasets[4].push({
x, x,
y: getConvertedTemperature(data.atemp[index], units.temperature), y: optional(data.atemp[index], (value) =>
getConvertedTemperature(value, units.temperature)
),
index, index,
}); });
} }
if (data.power) { if (data.power) {
datasets[5].push({ x, y: data.power[index], index }); datasets[5].push({ x, y: optional(data.power[index]), index });
} }
} }