navigo 0.8.1

GPS/geospatial data for Rust — trace analysis, GPX parsing, Minetti pace model, race route analysis (legs/sections/stages), live calibration.
Documentation
use crate::pace_model::{AnalysisOptions, WeatherConditions, WeatherLookup};

// ── Options for analyzeGpx / Trace::analyze / Trace::recalibrate ─────────────

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WasmWeatherEntry {
    name: String,
    temperature_c: f64,
    humidity_pct: f64,
    wind_kmh: f64,
    precip_prob_pct: f64,
}

/// Shared by `WasmAnalyzeOptions` and `WasmRecalibrateOptions` — both carry an
/// optional `weather` array with the same shape.
fn weather_lookup_from(entries: &[WasmWeatherEntry]) -> WeatherLookup {
    if entries.is_empty() {
        return WeatherLookup::empty();
    }

    let mut names = Vec::with_capacity(entries.len());
    let mut values = Vec::with_capacity(entries.len());
    for w in entries {
        names.push(w.name.clone());
        values.push(WeatherConditions {
            temperature_c: w.temperature_c,
            humidity_pct: w.humidity_pct,
            wind_kmh: w.wind_kmh,
            precip_prob_pct: w.precip_prob_pct,
        });
    }

    WeatherLookup::new(names, values)
}

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WasmAnalyzeOptions {
    base_pace_s_per_km: f64,
    k_fatigue: f64,
    life_base_stop_s: u32,
    #[serde(default)]
    weather: Vec<WasmWeatherEntry>,
}

impl WasmAnalyzeOptions {
    pub(crate) fn to_analysis_options(&self) -> AnalysisOptions {
        AnalysisOptions {
            base_pace_s_per_km: self.base_pace_s_per_km,
            k_fatigue: self.k_fatigue,
            life_base_stop_s: self.life_base_stop_s,
            weather: weather_lookup_from(&self.weather),
        }
    }
}

// ── Options for Trace::recalibrate ────────────────────────────────────────────

#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WasmRecalibrateOptions {
    base_pace_s_per_km: f64,
    k_fatigue: f64,
    life_base_stop_s: u32,
    current_index: u32,
    actual_elapsed_s: f64,
    #[serde(default)]
    weather: Vec<WasmWeatherEntry>,
}

impl WasmRecalibrateOptions {
    pub(crate) fn current_index(&self) -> usize {
        self.current_index as usize
    }

    pub(crate) fn actual_elapsed_s(&self) -> f64 {
        self.actual_elapsed_s
    }

    pub(crate) fn to_analysis_options(&self) -> AnalysisOptions {
        AnalysisOptions {
            base_pace_s_per_km: self.base_pace_s_per_km,
            k_fatigue: self.k_fatigue,
            life_base_stop_s: self.life_base_stop_s,
            weather: weather_lookup_from(&self.weather),
        }
    }
}

#[cfg(test)]
impl WasmAnalyzeOptions {
    /// Used by `wasm.rs`'s pipeline tests, which need a concrete options
    /// value but live outside this module.
    pub(crate) fn sample() -> Self {
        Self {
            base_pace_s_per_km: 500.0,
            k_fatigue: 0.002,
            life_base_stop_s: 3600,
            weather: Vec::new(),
        }
    }
}

#[cfg(test)]
impl WasmRecalibrateOptions {
    /// Used by `wasm.rs`'s pipeline tests, which need a concrete options
    /// value but live outside this module.
    pub(crate) fn sample(current_index: u32, actual_elapsed_s: f64) -> Self {
        Self {
            base_pace_s_per_km: 500.0,
            k_fatigue: 0.002,
            life_base_stop_s: 3600,
            current_index,
            actual_elapsed_s,
            weather: Vec::new(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn weather_lookup_empty_when_no_entries() {
        let options = WasmAnalyzeOptions {
            base_pace_s_per_km: 500.0,
            k_fatigue: 0.002,
            life_base_stop_s: 3600,
            weather: Vec::new(),
        };
        assert!((options.to_analysis_options().weather.factor_for("anything") - 1.0).abs() < 1e-9);
    }

    #[test]
    fn weather_lookup_converts_entries_and_matches_by_name() {
        let options = WasmAnalyzeOptions {
            base_pace_s_per_km: 500.0,
            k_fatigue: 0.002,
            life_base_stop_s: 3600,
            weather: vec![WasmWeatherEntry {
                name: "Chamonix".into(),
                temperature_c: 32.0,
                humidity_pct: 85.0,
                wind_kmh: 35.0,
                precip_prob_pct: 80.0,
            }],
        };
        let lookup = &options.to_analysis_options().weather;
        assert!(lookup.factor_for("Chamonix") > 1.0);
        assert!((lookup.factor_for("Unknown") - 1.0).abs() < 1e-9);
    }
}