use geo::{algorithm::simplify::Simplify, Coord, LineString};
use rstar::{RTreeObject, AABB};
use serde::{Deserialize, Serialize};
pub mod error;
pub use error::{OptionExt, Result, RouteMatchError};
pub mod union_find;
pub use union_find::UnionFind;
pub mod matching;
pub use matching::compare_routes;
pub mod grouping;
#[cfg(feature = "parallel")]
pub use grouping::{
group_incremental, group_signatures_parallel, group_signatures_parallel_with_matches,
};
pub use grouping::{group_signatures, group_signatures_with_matches, should_group_routes};
pub mod geo_utils;
pub mod algorithms;
pub mod lru_cache;
pub mod engine;
pub use engine::{with_engine, EngineStats, RouteEngine, ENGINE};
#[cfg(feature = "persistence")]
pub mod persistence;
#[cfg(feature = "persistence")]
pub use persistence::{
with_persistent_engine, PersistentEngineStats, PersistentRouteEngine, SectionDetectionHandle,
PERSISTENT_ENGINE,
};
#[cfg(feature = "http")]
pub mod http;
#[cfg(feature = "http")]
pub use http::{ActivityFetcher, ActivityMapResult, MapBounds};
pub mod sections;
pub use sections::{
detect_sections_from_tracks,
detect_sections_multiscale,
DetectionStats,
FrequentSection,
MultiScaleSectionResult,
PotentialSection,
ScalePreset,
SectionConfig,
SectionPortion,
};
pub mod heatmap;
pub use heatmap::{
generate_heatmap, query_heatmap_cell, ActivityHeatmapData, CellQueryResult, HeatmapBounds,
HeatmapCell, HeatmapConfig, HeatmapResult, RouteRef,
};
pub mod zones;
pub use zones::{
calculate_hr_zones, calculate_power_zones, HRZoneConfig, HRZoneDistribution, PowerZoneConfig,
PowerZoneDistribution,
};
#[cfg(feature = "parallel")]
pub use zones::{calculate_hr_zones_parallel, calculate_power_zones_parallel};
pub mod curves;
pub use curves::{compute_pace_curve, compute_power_curve, CurvePoint, PaceCurve, PowerCurve};
pub mod achievements;
pub use achievements::{detect_achievements, Achievement, AchievementType, ActivityRecord};
#[cfg(feature = "ffi")]
pub mod ffi;
#[cfg(feature = "ffi")]
uniffi::setup_scaffolding!();
#[cfg(all(feature = "ffi", target_os = "android"))]
pub(crate) fn init_logging() {
use android_logger::Config;
use log::LevelFilter;
android_logger::init_once(
Config::default()
.with_max_level(LevelFilter::Debug)
.with_tag("RouteMatcherRust"),
);
}
#[cfg(all(feature = "ffi", not(target_os = "android")))]
pub(crate) fn init_logging() {
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct GpsPoint {
pub latitude: f64,
pub longitude: f64,
}
impl GpsPoint {
pub fn new(latitude: f64, longitude: f64) -> Self {
Self {
latitude,
longitude,
}
}
pub fn is_valid(&self) -> bool {
self.latitude.is_finite()
&& self.longitude.is_finite()
&& self.latitude >= -90.0
&& self.latitude <= 90.0
&& self.longitude >= -180.0
&& self.longitude <= 180.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct Bounds {
pub min_lat: f64,
pub max_lat: f64,
pub min_lng: f64,
pub max_lng: f64,
}
impl Bounds {
pub fn from_points(points: &[GpsPoint]) -> Option<Self> {
if points.is_empty() {
return None;
}
let mut min_lat = f64::MAX;
let mut max_lat = f64::MIN;
let mut min_lng = f64::MAX;
let mut max_lng = f64::MIN;
for p in points {
min_lat = min_lat.min(p.latitude);
max_lat = max_lat.max(p.latitude);
min_lng = min_lng.min(p.longitude);
max_lng = max_lng.max(p.longitude);
}
Some(Self {
min_lat,
max_lat,
min_lng,
max_lng,
})
}
pub fn center(&self) -> GpsPoint {
GpsPoint::new(
(self.min_lat + self.max_lat) / 2.0,
(self.min_lng + self.max_lng) / 2.0,
)
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct RouteSignature {
pub activity_id: String,
pub points: Vec<GpsPoint>,
pub total_distance: f64,
pub start_point: GpsPoint,
pub end_point: GpsPoint,
pub bounds: Bounds,
pub center: GpsPoint,
}
impl RouteSignature {
pub fn from_points(
activity_id: &str,
points: &[GpsPoint],
config: &MatchConfig,
) -> Option<Self> {
if points.len() < 2 {
return None;
}
let coords: Vec<Coord> = points
.iter()
.filter(|p| p.is_valid())
.map(|p| Coord {
x: p.longitude,
y: p.latitude,
})
.collect();
if coords.len() < 2 {
return None;
}
let line = LineString::new(coords);
let simplified = line.simplify(&config.simplification_tolerance);
let final_coords: Vec<Coord> = if simplified.0.len() > config.max_simplified_points as usize
{
let step = simplified.0.len() as f64 / config.max_simplified_points as f64;
(0..config.max_simplified_points)
.map(|i| simplified.0[(i as f64 * step) as usize])
.collect()
} else {
simplified.0.clone()
};
if final_coords.len() < 2 {
return None;
}
let simplified_points: Vec<GpsPoint> = final_coords
.iter()
.map(|c| GpsPoint::new(c.y, c.x))
.collect();
let total_distance = calculate_route_distance(&simplified_points);
let bounds = Bounds::from_points(&simplified_points)?;
let center = bounds.center();
Some(Self {
activity_id: activity_id.to_string(),
start_point: simplified_points[0],
end_point: simplified_points[simplified_points.len() - 1],
points: simplified_points,
total_distance,
bounds,
center,
})
}
pub fn route_bounds(&self) -> RouteBounds {
RouteBounds {
activity_id: self.activity_id.clone(),
min_lat: self.bounds.min_lat,
max_lat: self.bounds.max_lat,
min_lng: self.bounds.min_lng,
max_lng: self.bounds.max_lng,
distance: self.total_distance,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct MatchResult {
pub activity_id_1: String,
pub activity_id_2: String,
pub match_percentage: f64,
pub direction: String,
pub amd: f64,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct MatchConfig {
pub perfect_threshold: f64,
pub zero_threshold: f64,
pub min_match_percentage: f64,
pub min_route_distance: f64,
pub max_distance_diff_ratio: f64,
pub endpoint_threshold: f64,
pub resample_count: u32,
pub simplification_tolerance: f64,
pub max_simplified_points: u32,
}
impl Default for MatchConfig {
fn default() -> Self {
Self {
perfect_threshold: 30.0,
zero_threshold: 250.0,
min_match_percentage: 65.0,
min_route_distance: 500.0,
max_distance_diff_ratio: 0.20,
endpoint_threshold: 200.0,
resample_count: 50,
simplification_tolerance: 0.0001,
max_simplified_points: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct RouteGroup {
pub group_id: String,
pub representative_id: String,
pub activity_ids: Vec<String>,
pub sport_type: String,
pub bounds: Option<Bounds>,
pub custom_name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityMatchInfo {
pub activity_id: String,
pub match_percentage: f64,
pub direction: String,
}
#[derive(Debug, Clone)]
pub struct GroupingResult {
pub groups: Vec<RouteGroup>,
pub activity_matches: std::collections::HashMap<String, Vec<ActivityMatchInfo>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct ActivityMetrics {
pub activity_id: String,
pub name: String,
pub date: i64,
pub distance: f64,
pub moving_time: u32,
pub elapsed_time: u32,
pub elevation_gain: f64,
pub avg_hr: Option<u16>,
pub avg_power: Option<u16>,
pub sport_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct RoutePerformance {
pub activity_id: String,
pub name: String,
pub date: i64,
pub speed: f64,
pub duration: u32,
pub moving_time: u32,
pub distance: f64,
pub elevation_gain: f64,
pub avg_hr: Option<u16>,
pub avg_power: Option<u16>,
pub is_current: bool,
pub direction: String,
pub match_percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct RoutePerformanceResult {
pub performances: Vec<RoutePerformance>,
pub best: Option<RoutePerformance>,
pub current_rank: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct SectionLap {
pub id: String,
pub activity_id: String,
pub time: f64,
pub pace: f64,
pub distance: f64,
pub direction: String,
pub start_index: u32,
pub end_index: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct SectionPerformanceRecord {
pub activity_id: String,
pub activity_name: String,
pub activity_date: i64,
pub laps: Vec<SectionLap>,
pub lap_count: u32,
pub best_time: f64,
pub best_pace: f64,
pub avg_time: f64,
pub avg_pace: f64,
pub direction: String,
pub section_distance: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
pub struct SectionPerformanceResult {
pub records: Vec<SectionPerformanceRecord>,
pub best_record: Option<SectionPerformanceRecord>,
}
#[derive(Debug, Clone)]
pub struct RouteBounds {
pub activity_id: String,
pub min_lat: f64,
pub max_lat: f64,
pub min_lng: f64,
pub max_lng: f64,
pub distance: f64,
}
impl RTreeObject for RouteBounds {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
AABB::from_corners([self.min_lng, self.min_lat], [self.max_lng, self.max_lat])
}
}
use crate::matching::calculate_route_distance;
#[cfg(test)]
mod tests {
use super::*;
fn sample_route() -> Vec<GpsPoint> {
vec![
GpsPoint::new(51.5074, -0.1278),
GpsPoint::new(51.5080, -0.1290),
GpsPoint::new(51.5090, -0.1300),
GpsPoint::new(51.5100, -0.1310),
GpsPoint::new(51.5110, -0.1320),
]
}
#[test]
fn test_gps_point_validation() {
assert!(GpsPoint::new(51.5074, -0.1278).is_valid());
assert!(!GpsPoint::new(91.0, 0.0).is_valid());
assert!(!GpsPoint::new(0.0, 181.0).is_valid());
assert!(!GpsPoint::new(f64::NAN, 0.0).is_valid());
}
#[test]
fn test_create_signature() {
let points = sample_route();
let sig = RouteSignature::from_points("test-1", &points, &MatchConfig::default());
assert!(sig.is_some());
let sig = sig.unwrap();
assert_eq!(sig.activity_id, "test-1");
assert!(sig.total_distance > 0.0);
}
#[test]
fn test_identical_routes_match() {
let points = sample_route();
let sig1 = RouteSignature::from_points("test-1", &points, &MatchConfig::default()).unwrap();
let sig2 = RouteSignature::from_points("test-2", &points, &MatchConfig::default()).unwrap();
let result = compare_routes(&sig1, &sig2, &MatchConfig::default());
assert!(result.is_some());
let result = result.unwrap();
assert!(result.match_percentage > 95.0);
assert_eq!(result.direction, "same");
}
#[test]
fn test_reverse_routes_match() {
let points = sample_route();
let mut reversed = points.clone();
reversed.reverse();
let sig1 = RouteSignature::from_points("test-1", &points, &MatchConfig::default()).unwrap();
let sig2 =
RouteSignature::from_points("test-2", &reversed, &MatchConfig::default()).unwrap();
let result = compare_routes(&sig1, &sig2, &MatchConfig::default());
assert!(result.is_some());
assert_eq!(result.unwrap().direction, "reverse");
}
#[test]
fn test_group_signatures() {
let long_route: Vec<GpsPoint> = (0..10)
.map(|i| GpsPoint::new(51.5074 + i as f64 * 0.001, -0.1278))
.collect();
let different_route: Vec<GpsPoint> = (0..10)
.map(|i| GpsPoint::new(40.7128 + i as f64 * 0.001, -74.0060))
.collect();
let sig1 =
RouteSignature::from_points("test-1", &long_route, &MatchConfig::default()).unwrap();
let sig2 =
RouteSignature::from_points("test-2", &long_route, &MatchConfig::default()).unwrap();
let sig3 = RouteSignature::from_points("test-3", &different_route, &MatchConfig::default())
.unwrap();
let groups = group_signatures(&[sig1, sig2, sig3], &MatchConfig::default());
assert_eq!(groups.len(), 2);
let group_with_1 = groups
.iter()
.find(|g| g.activity_ids.contains(&"test-1".to_string()))
.unwrap();
assert!(group_with_1.activity_ids.contains(&"test-2".to_string()));
assert!(!group_with_1.activity_ids.contains(&"test-3".to_string()));
}
}