Standalone binary embeds the frontend, avoiding the dev-server dependency that made the window fail to load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1239 lines
42 KiB
Rust
1239 lines
42 KiB
Rust
//! Turning a raw log into a FIT activity: aggregation, then assembly.
|
||
//!
|
||
//! Message order follows what Garmin devices produce, because that is what
|
||
//! every uploader has been tested against:
|
||
//!
|
||
//! ```text
|
||
//! file_id, device_info, event(timer/start),
|
||
//! [ record × n, lap ] × laps,
|
||
//! event(timer/stop_all), session, activity
|
||
//! ```
|
||
//!
|
||
//! Records carry no `position_lat`/`position_long`: an indoor ride has no GPS,
|
||
//! and inventing coordinates is worse than omitting them. Combined with
|
||
//! `sub_sport = virtual_activity` this is how Strava is told to treat the file
|
||
//! as a Virtual Ride rather than an outdoor ride whose GPS failed.
|
||
|
||
use crate::encode::{FitEncoder, Message, Value};
|
||
use crate::profile::{activity, device_info, enums, event, file_id, lap, mesg, record, session};
|
||
use crate::rawlog::{RawLog, Sample};
|
||
use crate::{timestamp, FitError};
|
||
|
||
/// Local message type allocation. FIT allows sixteen; we use seven, so no
|
||
/// definition ever has to be evicted and re-emitted.
|
||
mod local {
|
||
pub const FILE_ID: u8 = 0;
|
||
pub const DEVICE_INFO: u8 = 1;
|
||
pub const EVENT: u8 = 2;
|
||
pub const RECORD: u8 = 3;
|
||
pub const LAP: u8 = 4;
|
||
pub const SESSION: u8 = 5;
|
||
pub const ACTIVITY: u8 = 6;
|
||
}
|
||
|
||
/// What was written, for logging and for the UI to show after a ride.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct FitSummary {
|
||
/// Size of the encoded file in bytes.
|
||
pub bytes: usize,
|
||
/// Number of `record` messages.
|
||
pub records: usize,
|
||
/// Number of laps.
|
||
pub laps: usize,
|
||
/// Wall-clock duration of the session, seconds.
|
||
pub total_elapsed_s: f64,
|
||
/// Moving/recording time excluding explicit pauses, seconds.
|
||
pub total_timer_s: f64,
|
||
/// Virtual distance covered, metres.
|
||
pub total_distance_m: f64,
|
||
/// Cumulative climbing, metres.
|
||
pub total_ascent_m: u16,
|
||
/// Mean power over samples that reported one. `None` if none did.
|
||
pub avg_power_w: Option<u16>,
|
||
/// Peak power. `None` if no sample reported power.
|
||
pub max_power_w: Option<u16>,
|
||
/// Energy in kilocalories, from the trainer if it reports it and otherwise
|
||
/// derived from mechanical work.
|
||
pub total_calories: Option<u16>,
|
||
/// Number of BLE dropouts spanned (FR-8.5).
|
||
pub gaps: usize,
|
||
/// False when the source log had no clean end marker, i.e. this activity
|
||
/// was recovered from a crash.
|
||
pub recovered_from_crash: bool,
|
||
/// Journal lines that could not be parsed.
|
||
pub skipped_log_lines: usize,
|
||
}
|
||
|
||
/// Per-lap and per-session aggregates (FR-8: session/lap totals).
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
struct Aggregates {
|
||
start_fit: u32,
|
||
end_fit: u32,
|
||
total_elapsed_ms: u64,
|
||
total_timer_ms: u64,
|
||
start_distance_m: f64,
|
||
end_distance_m: f64,
|
||
power_sum: f64,
|
||
power_n: u32,
|
||
max_power: Option<u16>,
|
||
cadence_sum: f64,
|
||
cadence_n: u32,
|
||
max_cadence: Option<u8>,
|
||
speed_sum: f64,
|
||
speed_n: u32,
|
||
max_speed_mps: f64,
|
||
hr_sum: f64,
|
||
hr_n: u32,
|
||
max_hr: Option<u8>,
|
||
ascent_m: f64,
|
||
descent_m: f64,
|
||
grade_sum: f64,
|
||
grade_n: u32,
|
||
/// Mechanical work, joules, integrated from power. The basis for calories
|
||
/// when the trainer does not report energy directly.
|
||
work_j: f64,
|
||
/// Trainer-reported cumulative energy at the first and last sample.
|
||
energy_start: Option<u16>,
|
||
energy_end: Option<u16>,
|
||
records: usize,
|
||
}
|
||
|
||
impl Aggregates {
|
||
fn total_distance_m(&self) -> f64 {
|
||
(self.end_distance_m - self.start_distance_m).max(0.0)
|
||
}
|
||
|
||
fn avg_power(&self) -> Option<u16> {
|
||
(self.power_n > 0).then(|| clamp_u16(self.power_sum / f64::from(self.power_n)))
|
||
}
|
||
|
||
fn avg_cadence(&self) -> Option<u8> {
|
||
(self.cadence_n > 0).then(|| clamp_u8(self.cadence_sum / f64::from(self.cadence_n)))
|
||
}
|
||
|
||
fn avg_hr(&self) -> Option<u8> {
|
||
(self.hr_n > 0).then(|| clamp_u8(self.hr_sum / f64::from(self.hr_n)))
|
||
}
|
||
|
||
/// Average speed in m/s. Computed from distance over timer time rather than
|
||
/// by averaging the samples, so that it is consistent with the distance and
|
||
/// duration shown alongside it.
|
||
fn avg_speed_mps(&self) -> f64 {
|
||
if self.total_timer_ms == 0 {
|
||
return 0.0;
|
||
}
|
||
self.total_distance_m() / (self.total_timer_ms as f64 / 1000.0)
|
||
}
|
||
|
||
/// Calories.
|
||
///
|
||
/// Prefers the trainer's own cumulative figure. Otherwise it uses the
|
||
/// cycling convention that kilojoules of mechanical work and dietary
|
||
/// kilocalories are numerically near-equal — human efficiency of roughly
|
||
/// 24% and the 4.184 kJ/kcal conversion very nearly cancel. This is the
|
||
/// same approximation Strava and Garmin apply to a power-meter ride.
|
||
fn calories(&self) -> Option<u16> {
|
||
match (self.energy_start, self.energy_end) {
|
||
(Some(a), Some(b)) if b >= a && b > 0 => return Some(b - a),
|
||
_ => {}
|
||
}
|
||
let kcal = clamp_u16(self.work_j / 1000.0);
|
||
(kcal > 0).then_some(kcal)
|
||
}
|
||
|
||
fn avg_grade_pct(&self) -> Option<f64> {
|
||
(self.grade_n > 0).then(|| self.grade_sum / f64::from(self.grade_n))
|
||
}
|
||
}
|
||
|
||
/// A sample with its absolute FIT timestamp and altitude resolved.
|
||
struct Resolved {
|
||
sample: Sample,
|
||
fit_time: u32,
|
||
altitude_m: f64,
|
||
}
|
||
|
||
/// Encode a raw log as a FIT activity file.
|
||
///
|
||
/// This is the whole encoder: [`crate::Recorder::finish`] and
|
||
/// [`crate::build_fit_from_log`] both come through here, so a file rebuilt
|
||
/// after a crash is byte-identical to one written by a clean shutdown of the
|
||
/// same ride.
|
||
pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError> {
|
||
let start_fit = timestamp::from_unix_millis(log.start.start_unix_ms)?;
|
||
let resolved = resolve_samples(log, start_fit)?;
|
||
if resolved.is_empty() {
|
||
return Err(FitError::NoSamples);
|
||
}
|
||
|
||
let end_ms = resolved.last().map_or(0, |r| r.sample.elapsed_ms);
|
||
let lap_bounds = lap_boundaries(log, end_ms);
|
||
let paused_ms = log.paused_ms(end_ms);
|
||
|
||
// Split samples into laps by elapsed time. A lap owns samples in
|
||
// [start, end); the last lap owns everything remaining.
|
||
let mut lap_aggs: Vec<Aggregates> = Vec::with_capacity(lap_bounds.len());
|
||
let mut lap_slices: Vec<(usize, usize)> = Vec::with_capacity(lap_bounds.len());
|
||
let mut cursor = 0usize;
|
||
for (i, &(lap_start_ms, lap_end_ms)) in lap_bounds.iter().enumerate() {
|
||
let is_last = i + 1 == lap_bounds.len();
|
||
let begin = cursor;
|
||
while cursor < resolved.len() {
|
||
let t = resolved[cursor].sample.elapsed_ms;
|
||
if !is_last && t >= lap_end_ms {
|
||
break;
|
||
}
|
||
cursor += 1;
|
||
}
|
||
lap_slices.push((begin, cursor));
|
||
// Pause time attributable to this lap.
|
||
let lap_paused = paused_within(log, lap_start_ms, lap_end_ms, end_ms);
|
||
// The first sample of the *next* lap closes this one's distance and
|
||
// altitude, so that the laps tile the session exactly rather than each
|
||
// dropping the stretch between its last sample and the next boundary.
|
||
let tail = resolved.get(cursor);
|
||
lap_aggs.push(aggregate(
|
||
&resolved[begin..cursor],
|
||
tail,
|
||
start_fit,
|
||
lap_start_ms,
|
||
lap_end_ms,
|
||
lap_paused,
|
||
));
|
||
}
|
||
|
||
let session_agg = aggregate(&resolved, None, start_fit, 0, end_ms, paused_ms);
|
||
let bytes = assemble(log, &resolved, &lap_slices, &lap_aggs, &session_agg, start_fit)?;
|
||
|
||
let summary = FitSummary {
|
||
bytes: bytes.len(),
|
||
records: resolved.len(),
|
||
laps: lap_aggs.len(),
|
||
total_elapsed_s: session_agg.total_elapsed_ms as f64 / 1000.0,
|
||
total_timer_s: session_agg.total_timer_ms as f64 / 1000.0,
|
||
total_distance_m: session_agg.total_distance_m(),
|
||
total_ascent_m: clamp_u16(session_agg.ascent_m),
|
||
avg_power_w: session_agg.avg_power(),
|
||
max_power_w: session_agg.max_power,
|
||
total_calories: session_agg.calories(),
|
||
gaps: log.gaps(end_ms).len(),
|
||
recovered_from_crash: !log.clean_shutdown,
|
||
skipped_log_lines: log.skipped_lines,
|
||
};
|
||
Ok((bytes, summary))
|
||
}
|
||
|
||
/// Attach absolute timestamps and altitudes to the samples.
|
||
///
|
||
/// Altitude: if the sample carries one (from a GPX route) it is used verbatim.
|
||
/// Otherwise a profile is synthesised by integrating gradient over distance,
|
||
/// which is the only altitude an indoor ride has. Without it Strava draws a
|
||
/// flat line for a ride up a simulated climb.
|
||
fn resolve_samples(log: &RawLog, start_fit: u32) -> Result<Vec<Resolved>, FitError> {
|
||
let mut out: Vec<Resolved> = Vec::new();
|
||
let mut altitude = 0.0f64;
|
||
let mut prev_distance: Option<f64> = None;
|
||
let mut prev_time: Option<u32> = None;
|
||
|
||
for sample in log.samples() {
|
||
let fit_time = start_fit
|
||
.checked_add(u32::try_from(sample.elapsed_ms / 1000).unwrap_or(u32::MAX))
|
||
.ok_or(FitError::TimestampOutOfRange {
|
||
unix_secs: i64::from(u32::MAX),
|
||
})?;
|
||
|
||
// FIT record timestamps have one-second resolution. Two samples in the
|
||
// same second would produce duplicate timestamps, which some parsers
|
||
// treat as corruption; keep the later one.
|
||
if prev_time == Some(fit_time) {
|
||
out.pop();
|
||
}
|
||
prev_time = Some(fit_time);
|
||
|
||
let delta_d = match prev_distance {
|
||
Some(prev) => (sample.distance_m - prev).max(0.0),
|
||
None => 0.0,
|
||
};
|
||
prev_distance = Some(sample.distance_m);
|
||
|
||
let altitude_m = match sample.altitude_m {
|
||
Some(a) => {
|
||
altitude = f64::from(a);
|
||
altitude
|
||
}
|
||
None => {
|
||
altitude += delta_d * f64::from(sample.gradient_pct) / 100.0;
|
||
altitude
|
||
}
|
||
};
|
||
|
||
out.push(Resolved {
|
||
sample: sample.clone(),
|
||
fit_time,
|
||
altitude_m,
|
||
});
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Lap boundaries as `(start_ms, end_ms)` pairs covering the whole ride.
|
||
///
|
||
/// A lap marker at time `t` ends the lap in progress at `t` and starts the next
|
||
/// one there. Markers at or beyond the end of the ride, and duplicates, are
|
||
/// ignored — a zero-length lap makes some importers unhappy and carries no
|
||
/// information.
|
||
fn lap_boundaries(log: &RawLog, end_ms: u64) -> Vec<(u64, u64)> {
|
||
let mut marks: Vec<u64> = log.lap_marks().filter(|&t| t > 0 && t < end_ms).collect();
|
||
marks.sort_unstable();
|
||
marks.dedup();
|
||
|
||
let mut bounds = Vec::with_capacity(marks.len() + 1);
|
||
let mut prev = 0u64;
|
||
for m in marks {
|
||
bounds.push((prev, m));
|
||
prev = m;
|
||
}
|
||
bounds.push((prev, end_ms));
|
||
bounds
|
||
}
|
||
|
||
/// Pause time falling inside `[from_ms, to_ms)`.
|
||
fn paused_within(log: &RawLog, from_ms: u64, to_ms: u64, fallback_end_ms: u64) -> u64 {
|
||
use crate::rawlog::LogEntry;
|
||
let mut total = 0u64;
|
||
let mut paused_at: Option<u64> = None;
|
||
for entry in &log.entries {
|
||
match entry {
|
||
LogEntry::Pause { at_ms } => {
|
||
if paused_at.is_none() {
|
||
paused_at = Some(*at_ms);
|
||
}
|
||
}
|
||
LogEntry::Resume { at_ms } => {
|
||
if let Some(start) = paused_at.take() {
|
||
total += overlap(start, *at_ms, from_ms, to_ms);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if let Some(start) = paused_at {
|
||
total += overlap(start, fallback_end_ms, from_ms, to_ms);
|
||
}
|
||
total
|
||
}
|
||
|
||
fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
|
||
a1.min(b1).saturating_sub(a0.max(b0))
|
||
}
|
||
|
||
/// Fold a slice of samples into lap or session aggregates.
|
||
///
|
||
/// `tail` is the first sample *after* this slice, when there is one. It
|
||
/// contributes only to the closing distance and altitude, never to averages or
|
||
/// maxima — it belongs to the next lap. Without it, lap distances and ascents
|
||
/// would not sum to the session's, because each lap would silently drop the
|
||
/// stretch between its final sample and the lap boundary.
|
||
fn aggregate(
|
||
samples: &[Resolved],
|
||
tail: Option<&Resolved>,
|
||
start_fit: u32,
|
||
from_ms: u64,
|
||
to_ms: u64,
|
||
paused_ms: u64,
|
||
) -> Aggregates {
|
||
let mut agg = Aggregates {
|
||
start_fit: start_fit + (from_ms / 1000) as u32,
|
||
end_fit: start_fit + (to_ms / 1000) as u32,
|
||
total_elapsed_ms: to_ms.saturating_sub(from_ms),
|
||
records: samples.len(),
|
||
..Default::default()
|
||
};
|
||
agg.total_timer_ms = agg.total_elapsed_ms.saturating_sub(paused_ms);
|
||
|
||
let mut prev_alt: Option<f64> = None;
|
||
let mut prev_ms: Option<u64> = None;
|
||
|
||
for (i, r) in samples.iter().enumerate() {
|
||
let s = &r.sample;
|
||
if i == 0 {
|
||
agg.start_distance_m = s.distance_m;
|
||
agg.energy_start = s.energy_kcal;
|
||
}
|
||
agg.end_distance_m = s.distance_m;
|
||
if s.energy_kcal.is_some() {
|
||
agg.energy_end = s.energy_kcal;
|
||
}
|
||
|
||
// Sample interval, for work integration. Clamped so that a long BLE
|
||
// dropout does not silently attribute minutes of work to one sample.
|
||
let dt_s = match prev_ms {
|
||
Some(prev) => ((s.elapsed_ms.saturating_sub(prev)) as f64 / 1000.0).clamp(0.0, 10.0),
|
||
None => 0.0,
|
||
};
|
||
prev_ms = Some(s.elapsed_ms);
|
||
|
||
if let Some(p) = s.power_w {
|
||
let p = f64::from(p).max(0.0);
|
||
agg.power_sum += p;
|
||
agg.power_n += 1;
|
||
let pw = clamp_u16(p);
|
||
agg.max_power = Some(agg.max_power.map_or(pw, |m| m.max(pw)));
|
||
agg.work_j += p * dt_s;
|
||
}
|
||
if let Some(c) = s.cadence_rpm {
|
||
if c.is_finite() {
|
||
agg.cadence_sum += f64::from(c);
|
||
agg.cadence_n += 1;
|
||
let cu = clamp_u8(f64::from(c));
|
||
agg.max_cadence = Some(agg.max_cadence.map_or(cu, |m| m.max(cu)));
|
||
}
|
||
}
|
||
if s.speed_kph.is_finite() {
|
||
let mps = f64::from(s.speed_kph) / 3.6;
|
||
agg.speed_sum += mps;
|
||
agg.speed_n += 1;
|
||
agg.max_speed_mps = agg.max_speed_mps.max(mps);
|
||
}
|
||
if let Some(h) = s.heart_rate_bpm {
|
||
if h > 0 {
|
||
agg.hr_sum += f64::from(h);
|
||
agg.hr_n += 1;
|
||
agg.max_hr = Some(agg.max_hr.map_or(h, |m| m.max(h)));
|
||
}
|
||
}
|
||
if s.gradient_pct.is_finite() {
|
||
agg.grade_sum += f64::from(s.gradient_pct);
|
||
agg.grade_n += 1;
|
||
}
|
||
|
||
if let Some(prev) = prev_alt {
|
||
let d = r.altitude_m - prev;
|
||
if d > 0.0 {
|
||
agg.ascent_m += d;
|
||
} else {
|
||
agg.descent_m -= d;
|
||
}
|
||
}
|
||
prev_alt = Some(r.altitude_m);
|
||
}
|
||
|
||
// Close the lap at the boundary rather than at its last sample.
|
||
if let Some(t) = tail {
|
||
agg.end_distance_m = t.sample.distance_m;
|
||
if let Some(prev) = prev_alt {
|
||
let d = t.altitude_m - prev;
|
||
if d > 0.0 {
|
||
agg.ascent_m += d;
|
||
} else {
|
||
agg.descent_m -= d;
|
||
}
|
||
}
|
||
}
|
||
|
||
agg
|
||
}
|
||
|
||
/// Emit the message stream.
|
||
fn assemble(
|
||
log: &RawLog,
|
||
resolved: &[Resolved],
|
||
lap_slices: &[(usize, usize)],
|
||
lap_aggs: &[Aggregates],
|
||
session_agg: &Aggregates,
|
||
start_fit: u32,
|
||
) -> Result<Vec<u8>, FitError> {
|
||
let mut enc = FitEncoder::new();
|
||
let end_fit = session_agg.end_fit;
|
||
|
||
// --- file_id -----------------------------------------------------------
|
||
let mut m = Message::new();
|
||
m.set(file_id::TYPE, Value::Enum(enums::FILE_ACTIVITY));
|
||
m.set(
|
||
file_id::MANUFACTURER,
|
||
Value::Uint16(enums::MANUFACTURER_DEVELOPMENT),
|
||
);
|
||
m.set(file_id::PRODUCT, Value::Uint16(1));
|
||
m.set(
|
||
file_id::SERIAL_NUMBER,
|
||
Value::Uint32z(log.start.serial_number),
|
||
);
|
||
m.set(file_id::TIME_CREATED, Value::Uint32(start_fit));
|
||
m.set(
|
||
file_id::PRODUCT_NAME,
|
||
Value::String(log.start.product_name.clone()),
|
||
);
|
||
enc.write_message(local::FILE_ID, mesg::FILE_ID, &m);
|
||
|
||
// --- device_info -------------------------------------------------------
|
||
let mut m = Message::new();
|
||
m.set(device_info::TIMESTAMP, Value::Uint32(start_fit));
|
||
m.set(
|
||
device_info::DEVICE_INDEX,
|
||
Value::Uint8(enums::DEVICE_INDEX_CREATOR),
|
||
);
|
||
m.set(
|
||
device_info::MANUFACTURER,
|
||
Value::Uint16(enums::MANUFACTURER_DEVELOPMENT),
|
||
);
|
||
m.set(device_info::PRODUCT, Value::Uint16(1));
|
||
m.set(
|
||
device_info::SOFTWARE_VERSION,
|
||
Value::Uint16(log.start.software_version),
|
||
);
|
||
m.set(
|
||
device_info::SOURCE_TYPE,
|
||
Value::Enum(enums::SOURCE_TYPE_LOCAL),
|
||
);
|
||
m.set(
|
||
device_info::PRODUCT_NAME,
|
||
Value::String(log.start.product_name.clone()),
|
||
);
|
||
enc.write_message(local::DEVICE_INFO, mesg::DEVICE_INFO, &m);
|
||
|
||
// --- timer start -------------------------------------------------------
|
||
write_timer_event(&mut enc, start_fit, enums::EVENT_TYPE_START);
|
||
|
||
// --- records, laps -----------------------------------------------------
|
||
let field_mask = FieldMask::of(resolved);
|
||
for (lap_index, (&(begin, end), agg)) in lap_slices.iter().zip(lap_aggs).enumerate() {
|
||
for r in &resolved[begin..end] {
|
||
enc.write_message(local::RECORD, mesg::RECORD, &record_message(r, &field_mask));
|
||
}
|
||
let is_last = lap_index + 1 == lap_aggs.len();
|
||
enc.write_message(
|
||
local::LAP,
|
||
mesg::LAP,
|
||
&lap_message(lap_index as u16, agg, log.start.sub_sport, is_last),
|
||
);
|
||
}
|
||
|
||
// --- timer stop, session, activity -------------------------------------
|
||
write_timer_event(&mut enc, end_fit, enums::EVENT_TYPE_STOP_ALL);
|
||
enc.write_message(
|
||
local::SESSION,
|
||
mesg::SESSION,
|
||
&session_message(session_agg, log.start.sub_sport, lap_aggs.len() as u16),
|
||
);
|
||
|
||
let mut m = Message::new();
|
||
m.set(activity::TIMESTAMP, Value::Uint32(end_fit));
|
||
m.set(
|
||
activity::TOTAL_TIMER_TIME,
|
||
Value::Uint32(scale_ms_to_millis_u32(session_agg.total_timer_ms)),
|
||
);
|
||
m.set(activity::NUM_SESSIONS, Value::Uint16(1));
|
||
m.set(activity::TYPE, Value::Enum(enums::ACTIVITY_MANUAL));
|
||
m.set(activity::EVENT, Value::Enum(enums::EVENT_ACTIVITY));
|
||
m.set(activity::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
|
||
m.set(
|
||
activity::LOCAL_TIMESTAMP,
|
||
Value::Uint32(timestamp::to_local(end_fit, log.start.utc_offset_secs)),
|
||
);
|
||
enc.write_message(local::ACTIVITY, mesg::ACTIVITY, &m);
|
||
|
||
Ok(enc.finish())
|
||
}
|
||
|
||
fn write_timer_event(enc: &mut FitEncoder, at: u32, event_type: u8) {
|
||
let mut m = Message::new();
|
||
m.set(event::TIMESTAMP, Value::Uint32(at));
|
||
m.set(event::EVENT, Value::Enum(enums::EVENT_TIMER));
|
||
m.set(event::EVENT_TYPE, Value::Enum(event_type));
|
||
m.set(event::EVENT_GROUP, Value::Uint8(0));
|
||
enc.write_message(local::EVENT, mesg::EVENT, &m);
|
||
}
|
||
|
||
/// Which optional record fields any sample in the ride actually carries.
|
||
///
|
||
/// A definition message is shared by every record, so a field must be either
|
||
/// present throughout or absent throughout. Deciding once, up front, means a
|
||
/// ride without a heart-rate strap carries no heart-rate field at all rather
|
||
/// than an hour of "invalid" bytes that some importers render as a flat zero
|
||
/// trace.
|
||
struct FieldMask {
|
||
power: bool,
|
||
cadence: bool,
|
||
heart_rate: bool,
|
||
resistance: bool,
|
||
}
|
||
|
||
impl FieldMask {
|
||
fn of(resolved: &[Resolved]) -> Self {
|
||
Self {
|
||
power: resolved.iter().any(|r| r.sample.power_w.is_some()),
|
||
cadence: resolved.iter().any(|r| r.sample.cadence_rpm.is_some()),
|
||
heart_rate: resolved
|
||
.iter()
|
||
.any(|r| r.sample.heart_rate_bpm.is_some_and(|h| h > 0)),
|
||
resistance: resolved.iter().any(|r| r.sample.resistance.is_some()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build one `record` message (FR-8.1 / FR-8.3).
|
||
///
|
||
/// Fields the mask includes are always written; a sample missing one gets the
|
||
/// base type's invalid value, which is how FIT represents a momentary sensor
|
||
/// dropout within an otherwise-present stream.
|
||
fn record_message(r: &Resolved, mask: &FieldMask) -> Message {
|
||
let s = &r.sample;
|
||
let mut m = Message::new();
|
||
m.set(record::TIMESTAMP, Value::Uint32(r.fit_time));
|
||
|
||
// altitude: (metres + 500) * 5, uint16.
|
||
m.set(
|
||
record::ALTITUDE,
|
||
Value::Uint16(clamp_u16((r.altitude_m + 500.0) * 5.0)),
|
||
);
|
||
// distance: centimetres, uint32.
|
||
m.set(
|
||
record::DISTANCE,
|
||
Value::Uint32(clamp_u32(s.distance_m * 100.0)),
|
||
);
|
||
// speed: mm/s, uint16.
|
||
m.set(
|
||
record::SPEED,
|
||
Value::Uint16(clamp_u16(f64::from(s.speed_kph) / 3.6 * 1000.0)),
|
||
);
|
||
// grade: percent * 100, sint16.
|
||
m.set(
|
||
record::GRADE,
|
||
Value::Sint16(clamp_i16(f64::from(s.gradient_pct) * 100.0)),
|
||
);
|
||
|
||
if mask.power {
|
||
m.set(
|
||
record::POWER,
|
||
Value::Uint16(match s.power_w {
|
||
Some(p) => clamp_u16(f64::from(p).max(0.0)),
|
||
None => INVALID_U16,
|
||
}),
|
||
);
|
||
}
|
||
if mask.cadence {
|
||
m.set(
|
||
record::CADENCE,
|
||
Value::Uint8(match s.cadence_rpm {
|
||
Some(c) if c.is_finite() => clamp_u8(f64::from(c)),
|
||
_ => INVALID_U8,
|
||
}),
|
||
);
|
||
}
|
||
if mask.heart_rate {
|
||
m.set(
|
||
record::HEART_RATE,
|
||
Value::Uint8(match s.heart_rate_bpm {
|
||
Some(h) if h > 0 => h,
|
||
_ => INVALID_U8,
|
||
}),
|
||
);
|
||
}
|
||
if mask.resistance {
|
||
m.set(
|
||
record::RESISTANCE,
|
||
Value::Uint8(match s.resistance {
|
||
Some(v) => clamp_u8(f64::from(v)),
|
||
None => INVALID_U8,
|
||
}),
|
||
);
|
||
}
|
||
m
|
||
}
|
||
|
||
fn lap_message(index: u16, agg: &Aggregates, sub_sport: u8, is_last: bool) -> Message {
|
||
let mut m = Message::new();
|
||
m.set(lap::MESSAGE_INDEX, Value::Uint16(index));
|
||
m.set(lap::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||
m.set(lap::EVENT, Value::Enum(enums::EVENT_LAP));
|
||
m.set(lap::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
|
||
m.set(lap::START_TIME, Value::Uint32(agg.start_fit));
|
||
m.set(
|
||
lap::TOTAL_ELAPSED_TIME,
|
||
Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)),
|
||
);
|
||
m.set(
|
||
lap::TOTAL_TIMER_TIME,
|
||
Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)),
|
||
);
|
||
m.set(
|
||
lap::TOTAL_DISTANCE,
|
||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||
);
|
||
m.set_opt(lap::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||
m.set(
|
||
lap::AVG_SPEED,
|
||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||
);
|
||
m.set(
|
||
lap::MAX_SPEED,
|
||
Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)),
|
||
);
|
||
m.set_opt(lap::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8));
|
||
m.set_opt(lap::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8));
|
||
m.set_opt(lap::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8));
|
||
m.set_opt(lap::MAX_CADENCE, agg.max_cadence.map(Value::Uint8));
|
||
m.set_opt(lap::AVG_POWER, agg.avg_power().map(Value::Uint16));
|
||
m.set_opt(lap::MAX_POWER, agg.max_power.map(Value::Uint16));
|
||
m.set(lap::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m)));
|
||
m.set(lap::TOTAL_DESCENT, Value::Uint16(clamp_u16(agg.descent_m)));
|
||
m.set(lap::INTENSITY, Value::Enum(enums::INTENSITY_ACTIVE));
|
||
m.set(
|
||
lap::LAP_TRIGGER,
|
||
Value::Enum(if is_last {
|
||
enums::LAP_TRIGGER_SESSION_END
|
||
} else {
|
||
enums::LAP_TRIGGER_MANUAL
|
||
}),
|
||
);
|
||
m.set(lap::SPORT, Value::Enum(enums::SPORT_CYCLING));
|
||
m.set(lap::SUB_SPORT, Value::Enum(sub_sport));
|
||
m.set(lap::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j)));
|
||
m.set_opt(
|
||
lap::AVG_GRADE,
|
||
agg.avg_grade_pct()
|
||
.map(|g| Value::Sint16(clamp_i16(g * 100.0))),
|
||
);
|
||
m
|
||
}
|
||
|
||
fn session_message(agg: &Aggregates, sub_sport: u8, num_laps: u16) -> Message {
|
||
let mut m = Message::new();
|
||
m.set(session::MESSAGE_INDEX, Value::Uint16(0));
|
||
m.set(session::TIMESTAMP, Value::Uint32(agg.end_fit));
|
||
m.set(session::EVENT, Value::Enum(enums::EVENT_SESSION));
|
||
m.set(session::EVENT_TYPE, Value::Enum(enums::EVENT_TYPE_STOP));
|
||
m.set(session::START_TIME, Value::Uint32(agg.start_fit));
|
||
m.set(session::SPORT, Value::Enum(enums::SPORT_CYCLING));
|
||
m.set(session::SUB_SPORT, Value::Enum(sub_sport));
|
||
m.set(
|
||
session::TOTAL_ELAPSED_TIME,
|
||
Value::Uint32(scale_ms_to_millis_u32(agg.total_elapsed_ms)),
|
||
);
|
||
m.set(
|
||
session::TOTAL_TIMER_TIME,
|
||
Value::Uint32(scale_ms_to_millis_u32(agg.total_timer_ms)),
|
||
);
|
||
m.set(
|
||
session::TOTAL_DISTANCE,
|
||
Value::Uint32(clamp_u32(agg.total_distance_m() * 100.0)),
|
||
);
|
||
m.set_opt(session::TOTAL_CALORIES, agg.calories().map(Value::Uint16));
|
||
m.set(
|
||
session::AVG_SPEED,
|
||
Value::Uint16(clamp_u16(agg.avg_speed_mps() * 1000.0)),
|
||
);
|
||
m.set(
|
||
session::MAX_SPEED,
|
||
Value::Uint16(clamp_u16(agg.max_speed_mps * 1000.0)),
|
||
);
|
||
m.set_opt(session::AVG_HEART_RATE, agg.avg_hr().map(Value::Uint8));
|
||
m.set_opt(session::MAX_HEART_RATE, agg.max_hr.map(Value::Uint8));
|
||
m.set_opt(session::AVG_CADENCE, agg.avg_cadence().map(Value::Uint8));
|
||
m.set_opt(session::MAX_CADENCE, agg.max_cadence.map(Value::Uint8));
|
||
m.set_opt(session::AVG_POWER, agg.avg_power().map(Value::Uint16));
|
||
m.set_opt(session::MAX_POWER, agg.max_power.map(Value::Uint16));
|
||
m.set(session::TOTAL_ASCENT, Value::Uint16(clamp_u16(agg.ascent_m)));
|
||
m.set(
|
||
session::TOTAL_DESCENT,
|
||
Value::Uint16(clamp_u16(agg.descent_m)),
|
||
);
|
||
m.set(session::FIRST_LAP_INDEX, Value::Uint16(0));
|
||
m.set(session::NUM_LAPS, Value::Uint16(num_laps.max(1)));
|
||
m.set(
|
||
session::TRIGGER,
|
||
Value::Enum(enums::SESSION_TRIGGER_ACTIVITY_END),
|
||
);
|
||
m.set(session::TOTAL_WORK, Value::Uint32(clamp_u32(agg.work_j)));
|
||
m
|
||
}
|
||
|
||
// --- numeric helpers -------------------------------------------------------
|
||
|
||
/// The `uint16` invalid value.
|
||
const INVALID_U16: u16 = 0xFFFF;
|
||
/// The `uint8` invalid value.
|
||
const INVALID_U8: u8 = 0xFF;
|
||
|
||
/// Milliseconds as a FIT `uint32` scaled by 1000 (i.e. milliseconds), saturating.
|
||
fn scale_ms_to_millis_u32(ms: u64) -> u32 {
|
||
u32::try_from(ms).unwrap_or(u32::MAX - 1)
|
||
}
|
||
|
||
/// Round and clamp into `u16`, keeping clear of the invalid sentinel so a real
|
||
/// measurement is never mistaken for missing data.
|
||
fn clamp_u16(v: f64) -> u16 {
|
||
if !v.is_finite() || v <= 0.0 {
|
||
return 0;
|
||
}
|
||
v.round().min(f64::from(INVALID_U16 - 1)) as u16
|
||
}
|
||
|
||
fn clamp_u32(v: f64) -> u32 {
|
||
if !v.is_finite() || v <= 0.0 {
|
||
return 0;
|
||
}
|
||
v.round().min(f64::from(u32::MAX - 1)) as u32
|
||
}
|
||
|
||
fn clamp_u8(v: f64) -> u8 {
|
||
if !v.is_finite() || v <= 0.0 {
|
||
return 0;
|
||
}
|
||
v.round().min(f64::from(INVALID_U8 - 1)) as u8
|
||
}
|
||
|
||
fn clamp_i16(v: f64) -> i16 {
|
||
if !v.is_finite() {
|
||
return 0;
|
||
}
|
||
v.round().clamp(f64::from(i16::MIN + 1), f64::from(i16::MAX - 1)) as i16
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::rawlog::{LogEntry, SessionStart};
|
||
|
||
fn log_with(entries: Vec<LogEntry>) -> RawLog {
|
||
RawLog {
|
||
start: SessionStart {
|
||
start_unix_ms: 1_785_000_000_000,
|
||
..Default::default()
|
||
},
|
||
entries,
|
||
skipped_lines: 0,
|
||
clean_shutdown: true,
|
||
path: None,
|
||
}
|
||
}
|
||
|
||
fn ride(seconds: u64) -> RawLog {
|
||
let mut entries = Vec::new();
|
||
for i in 0..seconds {
|
||
entries.push(LogEntry::Sample(Sample {
|
||
elapsed_ms: i * 1000,
|
||
power_w: Some(200),
|
||
cadence_rpm: Some(90.0),
|
||
speed_kph: 36.0, // 10 m/s
|
||
distance_m: (i * 10) as f64,
|
||
gradient_pct: 0.0,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
entries.push(LogEntry::End {
|
||
at_ms: (seconds - 1) * 1000,
|
||
});
|
||
log_with(entries)
|
||
}
|
||
|
||
#[test]
|
||
fn clamps_behave_at_the_edges() {
|
||
assert_eq!(clamp_u16(-5.0), 0);
|
||
assert_eq!(clamp_u16(f64::NAN), 0);
|
||
assert_eq!(clamp_u16(1e30), 0xFFFE, "never reaches the invalid value");
|
||
assert_eq!(clamp_u16(2.5), 3);
|
||
assert_eq!(clamp_u8(1e9), 0xFE);
|
||
assert_eq!(clamp_u32(1e30), u32::MAX - 1);
|
||
assert_eq!(clamp_i16(-1e9), i16::MIN + 1);
|
||
assert_eq!(clamp_i16(1e9), i16::MAX - 1);
|
||
assert_eq!(clamp_i16(f64::NAN), 0);
|
||
assert_eq!(clamp_i16(-250.0), -250);
|
||
}
|
||
|
||
#[test]
|
||
fn a_log_with_no_samples_is_rejected_rather_than_written_empty() {
|
||
let log = log_with(vec![LogEntry::End { at_ms: 0 }]);
|
||
assert!(matches!(encode_activity(&log), Err(FitError::NoSamples)));
|
||
}
|
||
|
||
#[test]
|
||
fn one_lap_by_default_covering_the_whole_ride() {
|
||
let log = ride(10);
|
||
assert_eq!(lap_boundaries(&log, 9000), vec![(0, 9000)]);
|
||
}
|
||
|
||
#[test]
|
||
fn lap_markers_split_the_ride() {
|
||
let mut log = ride(100);
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 30_000,
|
||
from_controller: true,
|
||
});
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 60_000,
|
||
from_controller: false,
|
||
});
|
||
assert_eq!(lap_boundaries(&log, 99_000), vec![
|
||
(0, 30_000),
|
||
(30_000, 60_000),
|
||
(60_000, 99_000)
|
||
]);
|
||
}
|
||
|
||
#[test]
|
||
fn degenerate_lap_markers_are_ignored() {
|
||
let mut log = ride(50);
|
||
// At the very start, past the end, and a duplicate.
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 0,
|
||
from_controller: false,
|
||
});
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 999_999,
|
||
from_controller: false,
|
||
});
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 20_000,
|
||
from_controller: false,
|
||
});
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 20_000,
|
||
from_controller: false,
|
||
});
|
||
assert_eq!(lap_boundaries(&log, 49_000), vec![(0, 20_000), (20_000, 49_000)]);
|
||
}
|
||
|
||
#[test]
|
||
fn aggregates_match_hand_computed_values() {
|
||
let log = ride(11); // samples at 0..10 s, 10 m/s, 200 W, 90 rpm
|
||
let (_, summary) = encode_activity(&log).unwrap();
|
||
assert_eq!(summary.records, 11);
|
||
assert_eq!(summary.laps, 1);
|
||
assert_eq!(summary.total_elapsed_s, 10.0);
|
||
assert_eq!(summary.total_timer_s, 10.0);
|
||
assert_eq!(summary.total_distance_m, 100.0);
|
||
assert_eq!(summary.avg_power_w, Some(200));
|
||
assert_eq!(summary.max_power_w, Some(200));
|
||
// 200 W for ten one-second intervals = 2000 J = 2 kJ ~ 2 kcal.
|
||
assert_eq!(summary.total_calories, Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn pauses_reduce_timer_time_but_not_elapsed_time() {
|
||
let mut log = ride(101);
|
||
log.entries.push(LogEntry::Pause { at_ms: 20_000 });
|
||
log.entries.push(LogEntry::Resume { at_ms: 50_000 });
|
||
let (_, summary) = encode_activity(&log).unwrap();
|
||
assert_eq!(summary.total_elapsed_s, 100.0);
|
||
assert_eq!(summary.total_timer_s, 70.0);
|
||
}
|
||
|
||
#[test]
|
||
fn a_ble_dropout_is_a_hole_in_the_records_not_a_failure() {
|
||
// Samples 0..5 s, nothing for 30 s, then 35..40 s.
|
||
let mut entries = Vec::new();
|
||
for i in 0..6u64 {
|
||
entries.push(LogEntry::Sample(Sample {
|
||
elapsed_ms: i * 1000,
|
||
power_w: Some(200),
|
||
speed_kph: 36.0,
|
||
distance_m: (i * 10) as f64,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
entries.push(LogEntry::Gap {
|
||
at_ms: 5_000,
|
||
until_ms: Some(35_000),
|
||
reason: "peripheral disconnected".into(),
|
||
});
|
||
for i in 35..41u64 {
|
||
entries.push(LogEntry::Sample(Sample {
|
||
elapsed_ms: i * 1000,
|
||
power_w: Some(200),
|
||
speed_kph: 36.0,
|
||
distance_m: (i * 10) as f64,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
entries.push(LogEntry::End { at_ms: 40_000 });
|
||
let log = log_with(entries);
|
||
|
||
let (bytes, summary) = encode_activity(&log).unwrap();
|
||
assert!(crate::encode::verify(&bytes).is_ok());
|
||
assert_eq!(summary.records, 12, "only the samples we actually have");
|
||
assert_eq!(summary.gaps, 1);
|
||
// The timer keeps running across a dropout: the rider was still riding.
|
||
assert_eq!(summary.total_elapsed_s, 40.0);
|
||
assert_eq!(summary.total_timer_s, 40.0);
|
||
// Work is not inflated by attributing the whole 30 s gap to one sample.
|
||
assert!(
|
||
summary.total_calories.unwrap() < 8,
|
||
"gap must not be integrated as full-power work, got {:?}",
|
||
summary.total_calories
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn altitude_is_integrated_from_gradient_and_distance() {
|
||
// 100 m at 10% should climb 10 m.
|
||
let mut entries = Vec::new();
|
||
for i in 0..11u64 {
|
||
entries.push(LogEntry::Sample(Sample {
|
||
elapsed_ms: i * 1000,
|
||
power_w: Some(250),
|
||
speed_kph: 36.0,
|
||
distance_m: (i * 10) as f64,
|
||
gradient_pct: 10.0,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
entries.push(LogEntry::End { at_ms: 10_000 });
|
||
let log = log_with(entries);
|
||
let resolved = resolve_samples(&log, 0).unwrap();
|
||
assert!((resolved.last().unwrap().altitude_m - 10.0).abs() < 1e-9);
|
||
|
||
let (_, summary) = encode_activity(&log).unwrap();
|
||
assert_eq!(summary.total_ascent_m, 10);
|
||
}
|
||
|
||
#[test]
|
||
fn an_explicit_altitude_overrides_the_integrated_profile() {
|
||
let entries = vec![
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 0,
|
||
distance_m: 0.0,
|
||
altitude_m: Some(1200.0),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 1000,
|
||
distance_m: 10.0,
|
||
gradient_pct: 50.0,
|
||
altitude_m: Some(1205.0),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::End { at_ms: 1000 },
|
||
];
|
||
let log = log_with(entries);
|
||
let resolved = resolve_samples(&log, 0).unwrap();
|
||
assert_eq!(resolved[0].altitude_m, 1200.0);
|
||
assert_eq!(resolved[1].altitude_m, 1205.0);
|
||
}
|
||
|
||
#[test]
|
||
fn duplicate_second_samples_are_collapsed() {
|
||
// The engine ticks faster than 1 Hz; two samples landing in the same
|
||
// second must not produce two records with the same timestamp.
|
||
let entries = vec![
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 0,
|
||
power_w: Some(100),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 400,
|
||
power_w: Some(150),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 1000,
|
||
power_w: Some(200),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::End { at_ms: 1000 },
|
||
];
|
||
let log = log_with(entries);
|
||
let resolved = resolve_samples(&log, 100).unwrap();
|
||
assert_eq!(resolved.len(), 2);
|
||
assert_eq!(resolved[0].fit_time, 100);
|
||
assert_eq!(resolved[0].sample.power_w, Some(150), "later sample wins");
|
||
assert_eq!(resolved[1].fit_time, 101);
|
||
}
|
||
|
||
#[test]
|
||
fn the_record_definition_omits_sensors_the_ride_never_had() {
|
||
let log = ride(5); // power and cadence, no heart rate
|
||
let resolved = resolve_samples(&log, 0).unwrap();
|
||
let mask = FieldMask::of(&resolved);
|
||
assert!(mask.power);
|
||
assert!(mask.cadence);
|
||
assert!(!mask.heart_rate);
|
||
assert!(!mask.resistance);
|
||
|
||
let msg = record_message(&resolved[0], &mask);
|
||
let fields: Vec<u8> = msg.fields().iter().map(|(n, _)| *n).collect();
|
||
assert!(fields.contains(&record::POWER));
|
||
assert!(!fields.contains(&record::HEART_RATE));
|
||
}
|
||
|
||
#[test]
|
||
fn record_scaling_is_the_profile_scaling() {
|
||
let entries = vec![
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 0,
|
||
power_w: Some(250),
|
||
cadence_rpm: Some(92.4),
|
||
speed_kph: 36.0,
|
||
distance_m: 1234.56,
|
||
gradient_pct: -3.25,
|
||
heart_rate_bpm: Some(151),
|
||
altitude_m: Some(100.0),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::End { at_ms: 0 },
|
||
];
|
||
let log = log_with(entries);
|
||
let resolved = resolve_samples(&log, 0).unwrap();
|
||
let msg = record_message(&resolved[0], &FieldMask::of(&resolved));
|
||
let get = |n: u8| msg.fields().iter().find(|(f, _)| *f == n).map(|(_, v)| v.clone());
|
||
|
||
assert_eq!(get(record::POWER), Some(Value::Uint16(250)), "watts, unscaled");
|
||
assert_eq!(get(record::CADENCE), Some(Value::Uint8(92)), "rpm, rounded");
|
||
assert_eq!(get(record::SPEED), Some(Value::Uint16(10_000)), "mm/s");
|
||
assert_eq!(get(record::DISTANCE), Some(Value::Uint32(123_456)), "cm");
|
||
assert_eq!(get(record::GRADE), Some(Value::Sint16(-325)), "percent x100");
|
||
assert_eq!(get(record::HEART_RATE), Some(Value::Uint8(151)), "bpm");
|
||
// (100 m + 500) * 5
|
||
assert_eq!(get(record::ALTITUDE), Some(Value::Uint16(3000)));
|
||
}
|
||
|
||
#[test]
|
||
fn lap_and_session_do_not_share_field_numbers() {
|
||
// Guards the single most dangerous transcription error in this crate.
|
||
assert_ne!(lap::AVG_POWER, session::AVG_POWER);
|
||
assert_eq!(lap::AVG_POWER, 19);
|
||
assert_eq!(session::AVG_POWER, 20);
|
||
assert_eq!(lap::AVG_SPEED, 13);
|
||
assert_eq!(session::AVG_SPEED, 14);
|
||
assert_eq!(lap::TOTAL_ASCENT, 21);
|
||
assert_eq!(session::TOTAL_ASCENT, 22);
|
||
}
|
||
|
||
#[test]
|
||
fn lap_aggregates_sum_to_the_session() {
|
||
let mut log = ride(61);
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 20_000,
|
||
from_controller: true,
|
||
});
|
||
log.entries.push(LogEntry::Lap {
|
||
at_ms: 40_000,
|
||
from_controller: true,
|
||
});
|
||
let (bytes, summary) = encode_activity(&log).unwrap();
|
||
assert!(crate::encode::verify(&bytes).is_ok());
|
||
assert_eq!(summary.laps, 3);
|
||
assert_eq!(summary.records, 61);
|
||
assert_eq!(summary.total_elapsed_s, 60.0);
|
||
assert_eq!(summary.total_distance_m, 600.0);
|
||
}
|
||
|
||
#[test]
|
||
fn lap_distance_and_ascent_tile_the_session_exactly() {
|
||
// Each lap must be closed at the boundary, not at its last sample, or
|
||
// the laps quietly lose one sample interval of distance apiece.
|
||
let mut entries = Vec::new();
|
||
for i in 0..61u64 {
|
||
entries.push(LogEntry::Sample(Sample {
|
||
elapsed_ms: i * 1000,
|
||
power_w: Some(200),
|
||
speed_kph: 36.0,
|
||
distance_m: (i * 10) as f64,
|
||
gradient_pct: 5.0,
|
||
..Default::default()
|
||
}));
|
||
}
|
||
entries.push(LogEntry::Lap {
|
||
at_ms: 20_000,
|
||
from_controller: true,
|
||
});
|
||
entries.push(LogEntry::Lap {
|
||
at_ms: 40_000,
|
||
from_controller: true,
|
||
});
|
||
entries.push(LogEntry::End { at_ms: 60_000 });
|
||
let log = log_with(entries);
|
||
|
||
let start_fit = crate::timestamp::from_unix_millis(log.start.start_unix_ms).unwrap();
|
||
let resolved = resolve_samples(&log, start_fit).unwrap();
|
||
let bounds = lap_boundaries(&log, 60_000);
|
||
let mut cursor = 0usize;
|
||
let mut lap_distance = 0.0;
|
||
let mut lap_ascent = 0.0;
|
||
for (i, &(from, to)) in bounds.iter().enumerate() {
|
||
let begin = cursor;
|
||
let is_last = i + 1 == bounds.len();
|
||
while cursor < resolved.len() && (is_last || resolved[cursor].sample.elapsed_ms < to) {
|
||
cursor += 1;
|
||
}
|
||
let agg = aggregate(
|
||
&resolved[begin..cursor],
|
||
resolved.get(cursor),
|
||
start_fit,
|
||
from,
|
||
to,
|
||
0,
|
||
);
|
||
lap_distance += agg.total_distance_m();
|
||
lap_ascent += agg.ascent_m;
|
||
}
|
||
let session = aggregate(&resolved, None, start_fit, 0, 60_000, 0);
|
||
assert!(
|
||
(lap_distance - session.total_distance_m()).abs() < 1e-9,
|
||
"laps sum to {lap_distance} m, session is {} m",
|
||
session.total_distance_m()
|
||
);
|
||
assert!(
|
||
(lap_ascent - session.ascent_m).abs() < 1e-9,
|
||
"laps climb {lap_ascent} m, session climbs {} m",
|
||
session.ascent_m
|
||
);
|
||
assert_eq!(session.total_distance_m(), 600.0);
|
||
}
|
||
|
||
#[test]
|
||
fn trainer_reported_energy_is_preferred_for_calories() {
|
||
let entries = vec![
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 0,
|
||
power_w: Some(200),
|
||
energy_kcal: Some(10),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::Sample(Sample {
|
||
elapsed_ms: 60_000,
|
||
power_w: Some(200),
|
||
energy_kcal: Some(210),
|
||
..Default::default()
|
||
}),
|
||
LogEntry::End { at_ms: 60_000 },
|
||
];
|
||
let (_, summary) = encode_activity(&log_with(entries)).unwrap();
|
||
assert_eq!(summary.total_calories, Some(200));
|
||
}
|
||
|
||
#[test]
|
||
fn a_crashed_log_still_encodes_and_says_so() {
|
||
let mut log = ride(30);
|
||
log.entries.retain(|e| !matches!(e, LogEntry::End { .. }));
|
||
log.clean_shutdown = false;
|
||
log.skipped_lines = 1;
|
||
let (bytes, summary) = encode_activity(&log).unwrap();
|
||
assert!(crate::encode::verify(&bytes).is_ok());
|
||
assert!(summary.recovered_from_crash);
|
||
assert_eq!(summary.skipped_log_lines, 1);
|
||
assert_eq!(summary.records, 30);
|
||
}
|
||
|
||
#[test]
|
||
fn a_pre_epoch_start_time_is_rejected() {
|
||
let mut log = ride(5);
|
||
log.start.start_unix_ms = 0; // 1970
|
||
assert!(matches!(
|
||
encode_activity(&log),
|
||
Err(FitError::TimestampOutOfRange { .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn encoding_is_deterministic() {
|
||
// A file rebuilt from the same log must be byte-identical, which is
|
||
// what makes crash recovery trustworthy.
|
||
let log = ride(20);
|
||
let (a, _) = encode_activity(&log).unwrap();
|
||
let (b, _) = encode_activity(&log).unwrap();
|
||
assert_eq!(a, b);
|
||
}
|
||
}
|