Files
BikeControl/crates/fit/src/builder.rs
T
dtourolleandClaude Opus 5 3a2a787b7d Add Svelte GUI, FIT encoder and README
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>
2026-08-05 13:50:01 +02:00

1239 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}