navigo 0.9.0

GPS/geospatial data for Rust — trace analysis, GPX parsing, Minetti pace model, race route analysis (legs/sections/stages), live calibration.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="CopilotDiffPersistence">
    <option name="pendingDiffs">
      <map>
        <entry key="$PROJECT_DIR$/src/pace_model.rs">
          <value>
            <PendingDiffInfo>
              <option name="filePath" value="$PROJECT_DIR$/src/pace_model.rs" />
              <option name="originalContent" value="use crate::minetti;&#10;&#10;// ── Fatigue ───────────────────────────────────────────────────────────────────&#10;&#10;/// Default fatigue coefficient. Presets: Low=0.001, Moderate=0.002, High=0.003, Very high=0.004.&#10;pub const K_FATIGUE: f64 = 0.002;&#10;&#10;/// Fraction of accumulated effort distance recovered at a LifeBase checkpoint.&#10;pub const RECOVERY_LIFE_BASE: f64 = 0.20;&#10;&#10;/// Default planned stop time at a LifeBase in seconds (used when no per-waypoint override is set).&#10;pub const DEFAULT_LIFE_BASE_STOP_S: u32 = 3600;&#10;&#10;/// Default flat-terrain pace when no user pace is provided (500 s/km = 8:20/km).&#10;pub const DEFAULT_BASE_PACE_S_PER_KM: f64 = 500.0;&#10;&#10;/// Exponential fatigue multiplier (≥ 1.0). More realistic than linear: late-race slowdown accelerates.&#10;pub fn fatigue_factor(d_eff_km: f64, k: f64) -&gt; f64 {&#10;    (k * d_eff_km).exp()&#10;}&#10;&#10;// ── Circadian ─────────────────────────────────────────────────────────────────&#10;&#10;/// Circadian pace multiplier (≥ 1.0) based on UTC time-of-day.&#10;///&#10;/// Smooth half-cosine bump centred at 3:30 UTC (peak sleep-deprivation), ±2 h window, up to +15%.&#10;pub fn circadian_factor(unix_time_s: i64) -&gt; f64 {&#10;    let hours_utc = ((unix_time_s as f64) / 3600.0).rem_euclid(24.0);&#10;    let diff = hours_utc - 3.5;&#10;    if diff.abs() &lt; 2.0 {&#10;        let normalized = diff / 2.0;&#10;        1.0 + 0.15 * 0.5 * (1.0 + (std::f64::consts::PI * normalized).cos())&#10;    } else {&#10;        1.0&#10;    }&#10;}&#10;&#10;// ── Composite pace factor ─────────────────────────────────────────────────────&#10;&#10;pub struct PaceFactors {&#10;    /// Terrain-only Minetti factor. Drives effort-weighted distance (d_eff).&#10;    pub terrain: f64,&#10;    /// terrain × fatigue × circadian × weather. Applied to base pace to get segment time.&#10;    pub combined: f64,&#10;}&#10;&#10;pub fn compute_factors(&#10;    slope_frac: f64,&#10;    d_eff_km: f64,&#10;    k_fatigue: f64,&#10;    clock_start: Option&lt;i64&gt;,&#10;    now_s: f64,&#10;    weather_factor: f64,&#10;) -&gt; PaceFactors {&#10;    let terrain = minetti::pace_factor(slope_frac);&#10;    let fatigue = fatigue_factor(d_eff_km, k_fatigue);&#10;    let circadian = clock_start&#10;        .map(|t0| circadian_factor(t0 + now_s as i64))&#10;        .unwrap_or(1.0);&#10;    PaceFactors {&#10;        terrain,&#10;        combined: terrain * fatigue * circadian * weather_factor,&#10;    }&#10;}&#10;&#10;// ── Weather ───────────────────────────────────────────────────────────────────&#10;&#10;pub const WEATHER_T_OPT: f64 = 12.0;&#10;pub const WEATHER_HEAT_PER_C: f64 = 0.006;&#10;pub const WEATHER_COLD_THRESHOLD_C: f64 = 0.0;&#10;pub const WEATHER_COLD_PER_C: f64 = 0.003;&#10;pub const WEATHER_WIND_THRESHOLD_KMH: f64 = 15.0;&#10;pub const WEATHER_WIND_PER_KMH: f64 = 0.004;&#10;pub const WEATHER_WIND_MAX: f64 = 0.20;&#10;pub const WEATHER_PRECIP_MAX: f64 = 0.08;&#10;&#10;#[derive(Debug, Clone, Copy)]&#10;#[cfg_attr(feature = &quot;wasm&quot;, derive(serde::Serialize, serde::Deserialize))]&#10;pub struct WeatherConditions {&#10;    pub temperature_c: f64,&#10;    pub humidity_pct: f64,&#10;    pub wind_kmh: f64,&#10;    pub precip_prob_pct: f64,&#10;}&#10;&#10;/// Neutral forecast (cool, dry, calm). `weather_factor(WEATHER_NEUTRAL) == 1.0`.&#10;pub const WEATHER_NEUTRAL: WeatherConditions = WeatherConditions {&#10;    temperature_c: WEATHER_T_OPT,&#10;    humidity_pct: 50.0,&#10;    wind_kmh: 0.0,&#10;    precip_prob_pct: 0.0,&#10;};&#10;&#10;/// Apparent (feels-like) temperature. Humidity amplifies heat stress above 20 °C only.&#10;pub fn apparent_temp_c(temp_c: f64, humidity_pct: f64) -&gt; f64 {&#10;    if temp_c &lt;= 20.0 {&#10;        return temp_c;&#10;    }&#10;    let excess_rh = (humidity_pct.clamp(0.0, 100.0) - 40.0).max(0.0) / 10.0;&#10;    temp_c + excess_rh * (temp_c - 20.0) * 0.1&#10;}&#10;&#10;pub fn thermal_factor(temp_c: f64, humidity_pct: f64) -&gt; f64 {&#10;    let at = apparent_temp_c(temp_c, humidity_pct);&#10;    if at &gt; WEATHER_T_OPT {&#10;        1.0 + (at - WEATHER_T_OPT) * WEATHER_HEAT_PER_C&#10;    } else if temp_c &lt; WEATHER_COLD_THRESHOLD_C {&#10;        1.0 + (WEATHER_COLD_THRESHOLD_C - temp_c) * WEATHER_COLD_PER_C&#10;    } else {&#10;        1.0&#10;    }&#10;}&#10;&#10;pub fn wind_factor(wind_kmh: f64) -&gt; f64 {&#10;    if wind_kmh &lt;= WEATHER_WIND_THRESHOLD_KMH {&#10;        return 1.0;&#10;    }&#10;    1.0 + ((wind_kmh - WEATHER_WIND_THRESHOLD_KMH) * WEATHER_WIND_PER_KMH).min(WEATHER_WIND_MAX)&#10;}&#10;&#10;pub fn precip_factor(precip_prob_pct: f64) -&gt; f64 {&#10;    1.0 + WEATHER_PRECIP_MAX * precip_prob_pct.clamp(0.0, 100.0) / 100.0&#10;}&#10;&#10;pub fn weather_factor(c: WeatherConditions) -&gt; f64 {&#10;    thermal_factor(c.temperature_c, c.humidity_pct)&#10;        * wind_factor(c.wind_kmh)&#10;        * precip_factor(c.precip_prob_pct)&#10;}&#10;&#10;/// Name-keyed weather forecast table. Unknown names resolve to `WEATHER_NEUTRAL`.&#10;#[derive(Debug, Clone)]&#10;pub struct WeatherLookup {&#10;    names: Vec&lt;String&gt;,&#10;    values: Vec&lt;WeatherConditions&gt;,&#10;}&#10;&#10;impl WeatherLookup {&#10;    pub fn empty() -&gt; Self {&#10;        Self {&#10;            names: Vec::new(),&#10;            values: Vec::new(),&#10;        }&#10;    }&#10;&#10;    pub fn new(names: Vec&lt;String&gt;, values: Vec&lt;WeatherConditions&gt;) -&gt; Self {&#10;        Self { names, values }&#10;    }&#10;&#10;    pub fn find(&amp;self, name: &amp;str) -&gt; WeatherConditions {&#10;        self.names&#10;            .iter()&#10;            .zip(self.values.iter())&#10;            .find(|(n, _)| n.as_str() == name)&#10;            .map(|(_, v)| *v)&#10;            .unwrap_or(WEATHER_NEUTRAL)&#10;    }&#10;&#10;    pub fn factor_for(&amp;self, name: &amp;str) -&gt; f64 {&#10;        weather_factor(self.find(name))&#10;    }&#10;}&#10;&#10;// ── Analysis options (builder) ────────────────────────────────────────────────&#10;&#10;/// Grouped parameters for the pace model used by `section`, `stage` and&#10;/// `calibration` computations.  Use the builder methods or `Default` for&#10;/// sensible race-analysis defaults.&#10;#[derive(Debug, Clone)]&#10;pub struct AnalysisOptions {&#10;    /// Flat-terrain pace in seconds per km.&#10;    pub base_pace_s_per_km: f64,&#10;    /// Exponential fatigue coefficient.&#10;    pub k_fatigue: f64,&#10;    /// Default planned stop at LifeBase checkpoints (seconds).&#10;    pub life_base_stop_s: u32,&#10;    /// Per-checkpoint weather forecast.&#10;    pub weather: WeatherLookup,&#10;}&#10;&#10;impl Default for AnalysisOptions {&#10;    fn default() -&gt; Self {&#10;        Self {&#10;            base_pace_s_per_km: DEFAULT_BASE_PACE_S_PER_KM,&#10;            k_fatigue: K_FATIGUE,&#10;            life_base_stop_s: DEFAULT_LIFE_BASE_STOP_S,&#10;            weather: WeatherLookup::empty(),&#10;        }&#10;    }&#10;}&#10;&#10;impl AnalysisOptions {&#10;    /// Start building options from the defaults.&#10;    pub fn new() -&gt; Self {&#10;        Self::default()&#10;    }&#10;&#10;    /// Set flat-terrain pace (seconds per km).&#10;    pub fn base_pace(mut self, s_per_km: f64) -&gt; Self {&#10;        self.base_pace_s_per_km = s_per_km;&#10;        self&#10;    }&#10;&#10;    /// Set the exponential fatigue coefficient.&#10;    pub fn fatigue(mut self, k: f64) -&gt; Self {&#10;        self.k_fatigue = k;&#10;        self&#10;    }&#10;&#10;    /// Set the default LifeBase stop duration (seconds).&#10;    pub fn life_base_stop(mut self, seconds: u32) -&gt; Self {&#10;        self.life_base_stop_s = seconds;&#10;        self&#10;    }&#10;&#10;    /// Attach a weather forecast lookup table.&#10;    pub fn weather(mut self, lookup: WeatherLookup) -&gt; Self {&#10;        self.weather = lookup;&#10;        self&#10;    }&#10;}&#10;&#10;#[cfg(test)]&#10;mod tests {&#10;    use super::*;&#10;&#10;    #[test]&#10;    fn fatigue_zero_effort_is_one() {&#10;        assert!((fatigue_factor(0.0, K_FATIGUE) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn fatigue_grows_faster_than_linear() {&#10;        let d = 100.0;&#10;        let linear = 1.0 + K_FATIGUE * d;&#10;        assert!(fatigue_factor(d, K_FATIGUE) &gt; linear);&#10;    }&#10;&#10;    #[test]&#10;    fn fatigue_ultra_finish_approx_1_60() {&#10;        assert!((fatigue_factor(234.0, K_FATIGUE) - 1.60).abs() &lt; 0.01);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_noon_utc_is_neutral() {&#10;        assert!((circadian_factor(12 * 3600) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_peak_at_3_30_utc() {&#10;        assert!((circadian_factor(3 * 3600 + 30 * 60) - 1.15).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_always_at_least_one() {&#10;        let mut t = 0i64;&#10;        while t &lt; 24 * 3600 {&#10;            assert!(circadian_factor(t) &gt;= 1.0);&#10;            t += 600;&#10;        }&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_wraps_across_midnight() {&#10;        let t0 = 3i64 * 3600 + 30 * 60;&#10;        let t2 = 2 * 24 * 3600 + 3 * 3600 + 30 * 60;&#10;        assert!((circadian_factor(t0) - circadian_factor(t2)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn compute_factors_flat_fresh_neutral_is_one() {&#10;        let f = compute_factors(0.0, 0.0, K_FATIGUE, None, 0.0, 1.0);&#10;        assert!((f.terrain - 1.0).abs() &lt; 1e-9);&#10;        assert!((f.combined - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn compute_factors_combined_is_product_of_all() {&#10;        let slope_frac = 0.08;&#10;        let d_eff_km = 50.0;&#10;        let clock_start: i64 = 1_700_000_000;&#10;        let now_s = 3600.0;&#10;        let wf = 1.05;&#10;&#10;        let f = compute_factors(&#10;            slope_frac,&#10;            d_eff_km,&#10;            K_FATIGUE,&#10;            Some(clock_start),&#10;            now_s,&#10;            wf,&#10;        );&#10;        let expected = minetti::pace_factor(slope_frac)&#10;            * fatigue_factor(d_eff_km, K_FATIGUE)&#10;            * circadian_factor(clock_start + now_s as i64)&#10;            * wf;&#10;        assert!((f.combined - expected).abs() &lt; 1e-12);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_neutral_returns_one() {&#10;        assert!((weather_factor(WEATHER_NEUTRAL) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn thermal_heat_and_humidity_worse() {&#10;        let dry = thermal_factor(30.0, 40.0);&#10;        let humid = thermal_factor(30.0, 90.0);&#10;        assert!(dry &gt; 1.0);&#10;        assert!(humid &gt; dry);&#10;    }&#10;&#10;    #[test]&#10;    fn thermal_cold_penalty() {&#10;        assert!(((1.0 + 10.0 * WEATHER_COLD_PER_C) - thermal_factor(-10.0, 50.0)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn wind_capped_at_max() {&#10;        assert!((wind_factor(1000.0) - (1.0 + WEATHER_WIND_MAX)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn precip_zero_is_neutral_and_hundred_is_max() {&#10;        assert!((precip_factor(0.0) - 1.0).abs() &lt; 1e-9);&#10;        assert!((precip_factor(100.0) - (1.0 + WEATHER_PRECIP_MAX)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_lookup_empty_is_neutral() {&#10;        assert!((WeatherLookup::empty().factor_for(&quot;anything&quot;) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_lookup_finds_by_name() {&#10;        let hot = WeatherConditions {&#10;            temperature_c: 32.0,&#10;            humidity_pct: 85.0,&#10;            wind_kmh: 35.0,&#10;            precip_prob_pct: 80.0,&#10;        };&#10;        let lookup = WeatherLookup::new(vec![&quot;Chamonix&quot;.into()], vec![hot]);&#10;        assert!(lookup.factor_for(&quot;Chamonix&quot;) &gt; 1.0);&#10;        assert!((lookup.factor_for(&quot;Unknown&quot;) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;}&#10;" />
              <option name="updatedContent" value="use crate::minetti;&#10;&#10;// ── Fatigue ───────────────────────────────────────────────────────────────────&#10;&#10;/// Default fatigue coefficient. Presets: Low=0.001, Moderate=0.002, High=0.003, Very high=0.004.&#10;pub const K_FATIGUE: f64 = 0.002;&#10;&#10;/// Fraction of accumulated effort distance recovered at a LifeBase checkpoint.&#10;pub const RECOVERY_LIFE_BASE: f64 = 0.20;&#10;&#10;/// Default planned stop time at a LifeBase in seconds (used when no per-waypoint override is set).&#10;pub const DEFAULT_LIFE_BASE_STOP_S: u32 = 3600;&#10;&#10;/// Default flat-terrain pace when no user pace is provided (500 s/km = 8:20/km).&#10;pub const DEFAULT_BASE_PACE_S_PER_KM: f64 = 500.0;&#10;&#10;/// Exponential fatigue multiplier (≥ 1.0). More realistic than linear: late-race slowdown accelerates.&#10;pub fn fatigue_factor(d_eff_km: f64, k: f64) -&gt; f64 {&#10;    (k * d_eff_km).exp()&#10;}&#10;&#10;// ── Circadian ─────────────────────────────────────────────────────────────────&#10;&#10;/// Circadian pace multiplier (≥ 1.0) based on UTC time-of-day.&#10;///&#10;/// Smooth half-cosine bump centred at 3:30 UTC (peak sleep-deprivation), ±2 h window, up to +15%.&#10;pub fn circadian_factor(unix_time_s: i64) -&gt; f64 {&#10;    let hours_utc = ((unix_time_s as f64) / 3600.0).rem_euclid(24.0);&#10;    let diff = hours_utc - 3.5;&#10;    if diff.abs() &lt; 2.0 {&#10;        let normalized = diff / 2.0;&#10;        1.0 + 0.15 * 0.5 * (1.0 + (std::f64::consts::PI * normalized).cos())&#10;    } else {&#10;        1.0&#10;    }&#10;}&#10;&#10;// ── Composite pace factor ─────────────────────────────────────────────────────&#10;&#10;pub struct PaceFactors {&#10;    /// Terrain-only Minetti factor. Drives effort-weighted distance (d_eff).&#10;    pub terrain: f64,&#10;    /// terrain × fatigue × circadian × weather. Applied to base pace to get segment time.&#10;    pub combined: f64,&#10;}&#10;&#10;pub fn compute_factors(&#10;    slope_frac: f64,&#10;    d_eff_km: f64,&#10;    k_fatigue: f64,&#10;    clock_start: Option&lt;i64&gt;,&#10;    now_s: f64,&#10;    weather_factor: f64,&#10;) -&gt; PaceFactors {&#10;    let terrain = minetti::pace_factor(slope_frac);&#10;    let fatigue = fatigue_factor(d_eff_km, k_fatigue);&#10;    let circadian = clock_start&#10;        .map(|t0| circadian_factor(t0 + now_s as i64))&#10;        .unwrap_or(1.0);&#10;    PaceFactors {&#10;        terrain,&#10;        combined: terrain * fatigue * circadian * weather_factor,&#10;    }&#10;}&#10;&#10;// ── Weather ───────────────────────────────────────────────────────────────────&#10;&#10;pub const WEATHER_T_OPT: f64 = 12.0;&#10;pub const WEATHER_HEAT_PER_C: f64 = 0.006;&#10;pub const WEATHER_COLD_THRESHOLD_C: f64 = 0.0;&#10;pub const WEATHER_COLD_PER_C: f64 = 0.003;&#10;pub const WEATHER_WIND_THRESHOLD_KMH: f64 = 15.0;&#10;pub const WEATHER_WIND_PER_KMH: f64 = 0.004;&#10;pub const WEATHER_WIND_MAX: f64 = 0.20;&#10;pub const WEATHER_PRECIP_MAX: f64 = 0.08;&#10;&#10;#[derive(Debug, Clone, Copy)]&#10;#[cfg_attr(feature = &quot;wasm&quot;, derive(serde::Serialize, serde::Deserialize))]&#10;pub struct WeatherConditions {&#10;    pub temperature_c: f64,&#10;    pub humidity_pct: f64,&#10;    pub wind_kmh: f64,&#10;    pub precip_prob_pct: f64,&#10;}&#10;&#10;/// Neutral forecast (cool, dry, calm). `weather_factor(WEATHER_NEUTRAL) == 1.0`.&#10;pub const WEATHER_NEUTRAL: WeatherConditions = WeatherConditions {&#10;    temperature_c: WEATHER_T_OPT,&#10;    humidity_pct: 50.0,&#10;    wind_kmh: 0.0,&#10;    precip_prob_pct: 0.0,&#10;};&#10;&#10;/// Apparent (feels-like) temperature. Humidity amplifies heat stress above 20 °C only.&#10;pub fn apparent_temp_c(temp_c: f64, humidity_pct: f64) -&gt; f64 {&#10;    if temp_c &lt;= 20.0 {&#10;        return temp_c;&#10;    }&#10;    let excess_rh = (humidity_pct.clamp(0.0, 100.0) - 40.0).max(0.0) / 10.0;&#10;    temp_c + excess_rh * (temp_c - 20.0) * 0.1&#10;}&#10;&#10;pub fn thermal_factor(temp_c: f64, humidity_pct: f64) -&gt; f64 {&#10;    let at = apparent_temp_c(temp_c, humidity_pct);&#10;    if at &gt; WEATHER_T_OPT {&#10;        1.0 + (at - WEATHER_T_OPT) * WEATHER_HEAT_PER_C&#10;    } else if temp_c &lt; WEATHER_COLD_THRESHOLD_C {&#10;        1.0 + (WEATHER_COLD_THRESHOLD_C - temp_c) * WEATHER_COLD_PER_C&#10;    } else {&#10;        1.0&#10;    }&#10;}&#10;&#10;pub fn wind_factor(wind_kmh: f64) -&gt; f64 {&#10;    if wind_kmh &lt;= WEATHER_WIND_THRESHOLD_KMH {&#10;        return 1.0;&#10;    }&#10;    1.0 + ((wind_kmh - WEATHER_WIND_THRESHOLD_KMH) * WEATHER_WIND_PER_KMH).min(WEATHER_WIND_MAX)&#10;}&#10;&#10;pub fn precip_factor(precip_prob_pct: f64) -&gt; f64 {&#10;    1.0 + WEATHER_PRECIP_MAX * precip_prob_pct.clamp(0.0, 100.0) / 100.0&#10;}&#10;&#10;pub fn weather_factor(c: WeatherConditions) -&gt; f64 {&#10;    thermal_factor(c.temperature_c, c.humidity_pct)&#10;        * wind_factor(c.wind_kmh)&#10;        * precip_factor(c.precip_prob_pct)&#10;}&#10;&#10;/// Name-keyed weather forecast table. Unknown names resolve to `WEATHER_NEUTRAL`.&#10;#[derive(Debug, Clone)]&#10;pub struct WeatherLookup {&#10;    names: Vec&lt;String&gt;,&#10;    values: Vec&lt;WeatherConditions&gt;,&#10;}&#10;&#10;impl WeatherLookup {&#10;    pub fn empty() -&gt; Self {&#10;        Self {&#10;            names: Vec::new(),&#10;            values: Vec::new(),&#10;        }&#10;    }&#10;&#10;    pub fn new(names: Vec&lt;String&gt;, values: Vec&lt;WeatherConditions&gt;) -&gt; Self {&#10;        Self { names, values }&#10;    }&#10;&#10;    pub fn find(&amp;self, name: &amp;str) -&gt; WeatherConditions {&#10;        self.names&#10;            .iter()&#10;            .zip(self.values.iter())&#10;            .find(|(n, _)| n.as_str() == name)&#10;            .map(|(_, v)| *v)&#10;            .unwrap_or(WEATHER_NEUTRAL)&#10;    }&#10;&#10;    pub fn factor_for(&amp;self, name: &amp;str) -&gt; f64 {&#10;        weather_factor(self.find(name))&#10;    }&#10;}&#10;&#10;// ── Analysis options (builder) ────────────────────────────────────────────────&#10;&#10;/// Grouped parameters for the pace model used by `section`, `stage` and&#10;/// `calibration` computations.  Use the builder methods or `Default` for&#10;/// sensible race-analysis defaults.&#10;#[derive(Debug, Clone)]&#10;pub struct AnalysisOptions {&#10;    /// Flat-terrain pace in seconds per km.&#10;    pub base_pace_s_per_km: f64,&#10;    /// Exponential fatigue coefficient.&#10;    pub k_fatigue: f64,&#10;    /// Default planned stop at LifeBase checkpoints (seconds).&#10;    pub life_base_stop_s: u32,&#10;    /// Per-checkpoint weather forecast.&#10;    pub weather: WeatherLookup,&#10;}&#10;&#10;impl Default for AnalysisOptions {&#10;    fn default() -&gt; Self {&#10;        Self {&#10;            base_pace_s_per_km: DEFAULT_BASE_PACE_S_PER_KM,&#10;            k_fatigue: K_FATIGUE,&#10;            life_base_stop_s: DEFAULT_LIFE_BASE_STOP_S,&#10;            weather: WeatherLookup::empty(),&#10;        }&#10;    }&#10;}&#10;&#10;impl AnalysisOptions {&#10;    /// Start building options from the defaults.&#10;    pub fn new() -&gt; Self {&#10;        Self::default()&#10;    }&#10;&#10;    /// Set flat-terrain pace (seconds per km).&#10;    pub fn base_pace(mut self, s_per_km: f64) -&gt; Self {&#10;        self.base_pace_s_per_km = s_per_km;&#10;        self&#10;    }&#10;&#10;    /// Set the exponential fatigue coefficient.&#10;    pub fn fatigue(mut self, k: f64) -&gt; Self {&#10;        self.k_fatigue = k;&#10;        self&#10;    }&#10;&#10;    /// Set the default LifeBase stop duration (seconds).&#10;    pub fn life_base_stop(mut self, seconds: u32) -&gt; Self {&#10;        self.life_base_stop_s = seconds;&#10;        self&#10;    }&#10;&#10;    /// Attach a weather forecast lookup table.&#10;    pub fn weather(mut self, lookup: WeatherLookup) -&gt; Self {&#10;        self.weather = lookup;&#10;        self&#10;    }&#10;}&#10;&#10;#[cfg(test)]&#10;mod tests {&#10;    use super::*;&#10;&#10;    #[test]&#10;    fn fatigue_zero_effort_is_one() {&#10;        assert!((fatigue_factor(0.0, K_FATIGUE) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn fatigue_grows_faster_than_linear() {&#10;        let d = 100.0;&#10;        let linear = 1.0 + K_FATIGUE * d;&#10;        assert!(fatigue_factor(d, K_FATIGUE) &gt; linear);&#10;    }&#10;&#10;    #[test]&#10;    fn fatigue_ultra_finish_approx_1_60() {&#10;        assert!((fatigue_factor(234.0, K_FATIGUE) - 1.60).abs() &lt; 0.01);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_noon_utc_is_neutral() {&#10;        assert!((circadian_factor(12 * 3600) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_peak_at_3_30_utc() {&#10;        assert!((circadian_factor(3 * 3600 + 30 * 60) - 1.15).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_always_at_least_one() {&#10;        let mut t = 0i64;&#10;        while t &lt; 24 * 3600 {&#10;            assert!(circadian_factor(t) &gt;= 1.0);&#10;            t += 600;&#10;        }&#10;    }&#10;&#10;    #[test]&#10;    fn circadian_wraps_across_midnight() {&#10;        let t0 = 3i64 * 3600 + 30 * 60;&#10;        let t2 = 2 * 24 * 3600 + 3 * 3600 + 30 * 60;&#10;        assert!((circadian_factor(t0) - circadian_factor(t2)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn compute_factors_flat_fresh_neutral_is_one() {&#10;        let f = compute_factors(0.0, 0.0, K_FATIGUE, None, 0.0, 1.0);&#10;        assert!((f.terrain - 1.0).abs() &lt; 1e-9);&#10;        assert!((f.combined - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn compute_factors_combined_is_product_of_all() {&#10;        let slope_frac = 0.08;&#10;        let d_eff_km = 50.0;&#10;        let clock_start: i64 = 1_700_000_000;&#10;        let now_s = 3600.0;&#10;        let wf = 1.05;&#10;&#10;        let f = compute_factors(&#10;            slope_frac,&#10;            d_eff_km,&#10;            K_FATIGUE,&#10;            Some(clock_start),&#10;            now_s,&#10;            wf,&#10;        );&#10;        let expected = minetti::pace_factor(slope_frac)&#10;            * fatigue_factor(d_eff_km, K_FATIGUE)&#10;            * circadian_factor(clock_start + now_s as i64)&#10;            * wf;&#10;        assert!((f.combined - expected).abs() &lt; 1e-12);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_neutral_returns_one() {&#10;        assert!((weather_factor(WEATHER_NEUTRAL) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn thermal_heat_and_humidity_worse() {&#10;        let dry = thermal_factor(30.0, 40.0);&#10;        let humid = thermal_factor(30.0, 90.0);&#10;        assert!(dry &gt; 1.0);&#10;        assert!(humid &gt; dry);&#10;    }&#10;&#10;    #[test]&#10;    fn thermal_cold_penalty() {&#10;        assert!(((1.0 + 10.0 * WEATHER_COLD_PER_C) - thermal_factor(-10.0, 50.0)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn wind_capped_at_max() {&#10;        assert!((wind_factor(1000.0) - (1.0 + WEATHER_WIND_MAX)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn precip_zero_is_neutral_and_hundred_is_max() {&#10;        assert!((precip_factor(0.0) - 1.0).abs() &lt; 1e-9);&#10;        assert!((precip_factor(100.0) - (1.0 + WEATHER_PRECIP_MAX)).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_lookup_empty_is_neutral() {&#10;        assert!((WeatherLookup::empty().factor_for(&quot;anything&quot;) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn weather_lookup_finds_by_name() {&#10;        let hot = WeatherConditions {&#10;            temperature_c: 32.0,&#10;            humidity_pct: 85.0,&#10;            wind_kmh: 35.0,&#10;            precip_prob_pct: 80.0,&#10;        };&#10;        let lookup = WeatherLookup::new(vec![&quot;Chamonix&quot;.into()], vec![hot]);&#10;        assert!(lookup.factor_for(&quot;Chamonix&quot;) &gt; 1.0);&#10;        assert!((lookup.factor_for(&quot;Unknown&quot;) - 1.0).abs() &lt; 1e-9);&#10;    }&#10;&#10;    #[test]&#10;    fn analysis_options_new_equals_default() {&#10;        let a = AnalysisOptions::new();&#10;        let b = AnalysisOptions::default();&#10;        assert!((a.base_pace_s_per_km - b.base_pace_s_per_km).abs() &lt; 1e-9);&#10;        assert!((a.k_fatigue - b.k_fatigue).abs() &lt; 1e-9);&#10;        assert_eq!(a.life_base_stop_s, b.life_base_stop_s);&#10;    }&#10;&#10;    #[test]&#10;    fn analysis_options_builder_chain() {&#10;        let hot = WeatherConditions {&#10;            temperature_c: 30.0,&#10;            humidity_pct: 80.0,&#10;            wind_kmh: 20.0,&#10;            precip_prob_pct: 50.0,&#10;        };&#10;        let lookup = WeatherLookup::new(vec![&quot;CP1&quot;.into()], vec![hot]);&#10;        let opts = AnalysisOptions::new()&#10;            .base_pace(450.0)&#10;            .fatigue(0.003)&#10;            .life_base_stop(1200)&#10;            .weather(lookup);&#10;        assert!((opts.base_pace_s_per_km - 450.0).abs() &lt; 1e-9);&#10;        assert!((opts.k_fatigue - 0.003).abs() &lt; 1e-9);&#10;        assert_eq!(opts.life_base_stop_s, 1200);&#10;        assert!(opts.weather.factor_for(&quot;CP1&quot;) &gt; 1.0);&#10;    }&#10;}&#10;" />
            </PendingDiffInfo>
          </value>
        </entry>
      </map>
    </option>
  </component>
</project>