//! 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, /// Peak power. `None` if no sample reported power. pub max_power_w: Option, /// Energy in kilocalories, from the trainer if it reports it and otherwise /// derived from mechanical work. pub total_calories: Option, /// 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, cadence_sum: f64, cadence_n: u32, max_cadence: Option, speed_sum: f64, speed_n: u32, max_speed_mps: f64, hr_sum: f64, hr_n: u32, max_hr: Option, 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, energy_end: Option, 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 { (self.power_n > 0).then(|| clamp_u16(self.power_sum / f64::from(self.power_n))) } fn avg_cadence(&self) -> Option { (self.cadence_n > 0).then(|| clamp_u8(self.cadence_sum / f64::from(self.cadence_n))) } fn avg_hr(&self) -> Option { (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 { 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 { (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, 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 = 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, FitError> { let mut out: Vec = Vec::new(); let mut altitude = 0.0f64; let mut prev_distance: Option = None; let mut prev_time: Option = 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 = 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 = 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 = None; let mut prev_ms: Option = 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, 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) -> 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 = 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); } }