1mod consensus;
28mod evolution;
29mod medoid;
30mod optimized;
31mod overlap;
32mod portions;
33mod postprocess;
34mod rtree;
35mod traces;
36
37use crate::matching::calculate_route_distance;
38use crate::{GpsPoint, RouteGroup};
39use log::info;
40#[cfg(feature = "parallel")]
41use rayon::prelude::*;
42use serde::{Deserialize, Serialize};
43use std::collections::{HashMap, HashSet};
44
45#[cfg(feature = "persistence")]
46use crate::persistence::SectionDetectionProgress;
47
48pub(crate) use consensus::compute_consensus_polyline;
50pub(crate) use medoid::select_medoid;
51pub(crate) use overlap::{
52 FullTrackOverlap, OverlapCluster, cluster_overlaps, find_full_track_overlap,
53};
54pub(crate) use portions::compute_activity_portions;
55pub(crate) use postprocess::{
56 consolidate_fragments, filter_low_quality_sections, make_sections_exclusive,
57 merge_nearby_sections, remove_overlapping_sections, split_at_gradient_changes,
58 split_at_heading_changes, split_folding_sections, split_high_variance_sections,
59};
60pub(crate) use rtree::{IndexedPoint, bounds_overlap_tracks, build_rtree};
61pub(crate) use traces::extract_all_activity_traces;
62
63pub use evolution::{
65 SectionUpdateResult, merge_overlapping_sections, update_section_with_new_traces,
66};
67
68pub use optimized::{
70 IncrementalResult, SectionMatch, SplitResult, detect_sections_incremental,
71 detect_sections_optimized, find_sections_in_route, recalculate_section_polyline,
72 split_section_at_index, split_section_at_point,
73};
74
75pub(crate) fn compute_initial_stability(
78 observation_count: u32,
79 average_spread: f64,
80 proximity_threshold: f64,
81) -> f64 {
82 let obs_factor = (observation_count as f64 / 10.0).min(1.0);
84
85 let spread_factor = 1.0 - (average_spread / proximity_threshold).clamp(0.0, 1.0);
87
88 (obs_factor * 0.6 + spread_factor * 0.4).clamp(0.0, 1.0)
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
96pub struct ScalePreset {
97 pub name: String,
99 pub min_length: f64,
101 pub max_length: f64,
103 pub min_activities: u32,
105}
106
107impl ScalePreset {
108 pub fn short() -> Self {
109 Self {
110 name: "short".to_string(),
111 min_length: 100.0,
112 max_length: 500.0,
113 min_activities: 2,
114 }
115 }
116
117 pub fn medium() -> Self {
118 Self {
119 name: "medium".to_string(),
120 min_length: 500.0,
121 max_length: 2000.0,
122 min_activities: 2,
123 }
124 }
125
126 pub fn long() -> Self {
127 Self {
128 name: "long".to_string(),
129 min_length: 2000.0,
130 max_length: 5000.0,
131 min_activities: 3,
132 }
133 }
134
135 pub fn default_presets() -> Vec<Self> {
136 vec![Self::short(), Self::medium(), Self::long()]
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
144pub struct SectionConfig {
145 pub proximity_threshold: f64,
147 pub min_section_length: f64,
149 pub max_section_length: f64,
151 pub min_activities: u32,
153 pub cluster_tolerance: f64,
155 pub sample_points: u32,
157 pub detection_mode: String,
159 pub include_potentials: bool,
161 pub scale_presets: Vec<ScalePreset>,
163 pub preserve_hierarchy: bool,
165}
166
167impl Default for SectionConfig {
168 fn default() -> Self {
169 Self {
170 proximity_threshold: 50.0, min_section_length: 200.0, max_section_length: 5000.0, min_activities: 3, cluster_tolerance: 80.0, sample_points: 50, detection_mode: "discovery".to_string(),
177 include_potentials: true,
178 scale_presets: ScalePreset::default_presets(),
179 preserve_hierarchy: true,
180 }
181 }
182}
183
184impl SectionConfig {
185 pub fn discovery() -> Self {
187 Self {
188 detection_mode: "discovery".to_string(),
189 include_potentials: true,
190 scale_presets: ScalePreset::default_presets(),
191 preserve_hierarchy: true,
192 ..Default::default()
193 }
194 }
195
196 pub fn conservative() -> Self {
198 Self {
199 detection_mode: "conservative".to_string(),
200 include_potentials: false,
201 min_activities: 4,
202 scale_presets: vec![ScalePreset::medium(), ScalePreset::long()],
203 preserve_hierarchy: false,
204 ..Default::default()
205 }
206 }
207
208 pub fn legacy() -> Self {
210 Self {
211 detection_mode: "legacy".to_string(),
212 include_potentials: false,
213 scale_presets: vec![], preserve_hierarchy: false,
215 min_activities: 3,
216 ..Default::default()
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
225pub struct SectionPortion {
226 #[serde(alias = "activity_id")]
228 pub activity_id: String,
229 #[serde(alias = "start_index")]
231 pub start_index: u32,
232 #[serde(alias = "end_index")]
234 pub end_index: u32,
235 #[serde(alias = "distance_meters")]
237 pub distance_meters: f64,
238 pub direction: String,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
246pub struct FrequentSection {
247 pub id: String,
249 pub name: Option<String>,
251 #[serde(alias = "sport_type")]
253 pub sport_type: String,
254 pub polyline: Vec<GpsPoint>,
257 #[serde(alias = "representative_activity_id")]
259 pub representative_activity_id: String,
260 #[serde(alias = "activity_ids")]
262 pub activity_ids: Vec<String>,
263 #[serde(alias = "activity_portions")]
265 pub activity_portions: Vec<SectionPortion>,
266 #[serde(alias = "route_ids")]
268 pub route_ids: Vec<String>,
269 #[serde(alias = "visit_count")]
271 pub visit_count: u32,
272 #[serde(alias = "distance_meters")]
274 pub distance_meters: f64,
275 #[serde(alias = "activity_traces")]
278 pub activity_traces: HashMap<String, Vec<GpsPoint>>,
279 pub confidence: f64,
282 #[serde(alias = "observation_count")]
284 pub observation_count: u32,
285 #[serde(alias = "average_spread")]
288 pub average_spread: f64,
289 #[serde(alias = "point_density")]
292 pub point_density: Vec<u32>,
293 pub scale: Option<String>,
295
296 pub version: u32,
299 #[serde(alias = "is_user_defined")]
301 pub is_user_defined: bool,
302 #[serde(alias = "created_at")]
304 pub created_at: Option<String>,
305 #[serde(alias = "updated_at")]
307 pub updated_at: Option<String>,
308 pub stability: f64,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase")]
317#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
318pub struct PotentialSection {
319 pub id: String,
321 #[serde(alias = "sport_type")]
323 pub sport_type: String,
324 pub polyline: Vec<GpsPoint>,
326 #[serde(alias = "activity_ids")]
328 pub activity_ids: Vec<String>,
329 #[serde(alias = "visit_count")]
331 pub visit_count: u32,
332 #[serde(alias = "distance_meters")]
334 pub distance_meters: f64,
335 pub confidence: f64,
337 pub scale: String,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
343#[serde(rename_all = "camelCase")]
344#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
345pub struct MultiScaleSectionResult {
346 pub sections: Vec<FrequentSection>,
348 pub potentials: Vec<PotentialSection>,
350 pub stats: DetectionStats,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357#[cfg_attr(feature = "ffi", derive(uniffi::Record))]
358pub struct DetectionStats {
359 pub activities_processed: u32,
361 pub overlaps_found: u32,
363 pub sections_by_scale: HashMap<String, u32>,
365 pub potentials_by_scale: HashMap<String, u32>,
367}
368
369fn process_cluster(
371 idx: usize,
372 cluster: OverlapCluster,
373 sport_type: &str,
374 track_map: &HashMap<String, Vec<GpsPoint>>,
375 activity_to_route: &HashMap<&str, &str>,
376 config: &SectionConfig,
377 scale_name: Option<&str>,
378) -> Option<FrequentSection> {
379 let (representative_id, representative_polyline) = select_medoid(&cluster);
381
382 if representative_polyline.is_empty() {
383 return None;
384 }
385
386 let distance_meters = calculate_route_distance(&representative_polyline);
387
388 if distance_meters > config.max_section_length {
390 return None;
391 }
392
393 let activity_portions =
395 compute_activity_portions(&cluster, &representative_polyline, track_map, config);
396
397 let route_ids: Vec<String> = cluster
399 .activity_ids
400 .iter()
401 .filter_map(|aid| activity_to_route.get(aid.as_str()).map(|s| s.to_string()))
402 .collect::<HashSet<_>>()
403 .into_iter()
404 .collect();
405
406 let activity_id_vec: Vec<String> = cluster.activity_ids.iter().cloned().collect();
408 let activity_traces =
409 extract_all_activity_traces(&activity_id_vec, &representative_polyline, track_map);
410
411 let all_traces: Vec<Vec<GpsPoint>> = activity_traces.values().cloned().collect();
413
414 let consensus = compute_consensus_polyline(
416 &representative_polyline,
417 &all_traces,
418 config.proximity_threshold,
419 );
420
421 if consensus.polyline.len() < 2 {
423 return None;
424 }
425
426 let consensus_distance = calculate_route_distance(&consensus.polyline);
428
429 if consensus_distance < config.min_section_length {
431 return None;
432 }
433
434 let stability = compute_initial_stability(
436 consensus.observation_count,
437 consensus.average_spread,
438 config.proximity_threshold,
439 );
440
441 let activity_count = cluster.activity_ids.len();
443
444 Some(FrequentSection {
445 id: format!("sec_{}_{}", sport_type.to_lowercase(), idx),
446 name: None,
447 sport_type: sport_type.to_string(),
448 polyline: consensus.polyline,
449 representative_activity_id: representative_id,
450 activity_ids: cluster.activity_ids.into_iter().collect(),
451 activity_portions,
452 route_ids,
453 visit_count: activity_count as u32,
455 distance_meters: consensus_distance,
456 activity_traces,
457 confidence: consensus.confidence,
458 observation_count: consensus.observation_count,
459 average_spread: consensus.average_spread,
460 point_density: consensus.point_density,
461 scale: scale_name.map(|s| s.to_string()),
462 version: 1,
464 is_user_defined: false,
465 created_at: None,
466 updated_at: None,
467 stability,
468 })
469}
470
471pub fn detect_sections_from_tracks(
474 tracks: &[(String, Vec<GpsPoint>)], sport_types: &HashMap<String, String>,
476 groups: &[RouteGroup],
477 config: &SectionConfig,
478) -> Vec<FrequentSection> {
479 info!("[Sections] Detecting from {} full GPS tracks", tracks.len());
480
481 if tracks.len() < config.min_activities as usize {
482 return vec![];
483 }
484
485 let significant_groups: Vec<&RouteGroup> = groups
487 .iter()
488 .filter(|g| g.activity_ids.len() >= 2)
489 .collect();
490
491 let activity_to_route: HashMap<&str, &str> = significant_groups
493 .iter()
494 .flat_map(|g| {
495 g.activity_ids
496 .iter()
497 .map(|aid| (aid.as_str(), g.group_id.as_str()))
498 })
499 .collect();
500
501 info!(
503 "[Sections] Received {} groups, {} with 2+ activities, {} total activity mappings",
504 groups.len(),
505 significant_groups.len(),
506 activity_to_route.len()
507 );
508
509 let track_map: HashMap<String, Vec<GpsPoint>> = tracks
511 .iter()
512 .map(|(id, pts)| (id.clone(), pts.clone()))
513 .collect();
514
515 let mut tracks_by_sport: HashMap<String, Vec<(&str, &[GpsPoint])>> = HashMap::new();
517 for (activity_id, points) in tracks {
518 let sport = sport_types
519 .get(activity_id)
520 .cloned()
521 .unwrap_or_else(|| "Unknown".to_string());
522 tracks_by_sport
523 .entry(sport)
524 .or_default()
525 .push((activity_id.as_str(), points.as_slice()));
526 }
527
528 let mut all_sections: Vec<FrequentSection> = Vec::new();
529 let mut section_counter = 0;
530
531 for (sport_type, sport_tracks) in &tracks_by_sport {
533 if sport_tracks.len() < config.min_activities as usize {
534 continue;
535 }
536
537 info!(
538 "[Sections] Processing {} {} tracks",
539 sport_tracks.len(),
540 sport_type
541 );
542
543 let rtree_start = std::time::Instant::now();
544 #[cfg(feature = "parallel")]
545 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
546 .par_iter()
547 .map(|(_, pts)| build_rtree(pts))
548 .collect();
549
550 #[cfg(not(feature = "parallel"))]
551 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
552 .iter()
553 .map(|(_, pts)| build_rtree(pts))
554 .collect();
555
556 info!(
557 "[Sections] Built {} R-trees in {}ms",
558 rtrees.len(),
559 rtree_start.elapsed().as_millis()
560 );
561
562 let overlap_start = std::time::Instant::now();
564
565 let pairs: Vec<(usize, usize)> = (0..sport_tracks.len())
567 .flat_map(|i| ((i + 1)..sport_tracks.len()).map(move |j| (i, j)))
568 .collect();
569
570 let total_pairs = pairs.len();
571
572 #[cfg(feature = "parallel")]
574 let overlaps: Vec<FullTrackOverlap> = pairs
575 .into_par_iter()
576 .filter_map(|(i, j)| {
577 let (id_a, track_a) = sport_tracks[i];
578 let (id_b, track_b) = sport_tracks[j];
579
580 if !bounds_overlap_tracks(track_a, track_b, config.proximity_threshold) {
582 return None;
583 }
584
585 find_full_track_overlap(id_a, track_a, id_b, track_b, &rtrees[j], config)
587 })
588 .collect();
589
590 #[cfg(not(feature = "parallel"))]
591 let overlaps: Vec<FullTrackOverlap> = pairs
592 .into_iter()
593 .filter_map(|(i, j)| {
594 let (id_a, track_a) = sport_tracks[i];
595 let (id_b, track_b) = sport_tracks[j];
596
597 if !bounds_overlap_tracks(track_a, track_b, config.proximity_threshold) {
599 return None;
600 }
601
602 find_full_track_overlap(id_a, track_a, id_b, track_b, &rtrees[j], config)
604 })
605 .collect();
606
607 info!(
608 "[Sections] Found {} pairwise overlaps for {} ({} pairs) in {}ms",
609 overlaps.len(),
610 sport_type,
611 total_pairs,
612 overlap_start.elapsed().as_millis()
613 );
614
615 let cluster_start = std::time::Instant::now();
617 let clusters = cluster_overlaps(overlaps, config);
618
619 let significant_clusters: Vec<_> = clusters
621 .into_iter()
622 .filter(|c| c.activity_ids.len() >= config.min_activities as usize)
623 .collect();
624
625 info!(
626 "[Sections] {} significant clusters ({}+ activities) for {} in {}ms",
627 significant_clusters.len(),
628 config.min_activities,
629 sport_type,
630 cluster_start.elapsed().as_millis()
631 );
632
633 let section_convert_start = std::time::Instant::now();
635
636 let cluster_data: Vec<_> = significant_clusters.into_iter().enumerate().collect();
638
639 #[cfg(feature = "parallel")]
641 let sport_sections: Vec<FrequentSection> = cluster_data
642 .into_par_iter()
643 .filter_map(|(idx, cluster)| {
644 process_cluster(
645 idx,
646 cluster,
647 sport_type,
648 &track_map,
649 &activity_to_route,
650 config,
651 None,
652 )
653 })
654 .collect();
655
656 #[cfg(not(feature = "parallel"))]
657 let sport_sections: Vec<FrequentSection> = cluster_data
658 .into_iter()
659 .filter_map(|(idx, cluster)| {
660 process_cluster(
661 idx,
662 cluster,
663 sport_type,
664 &track_map,
665 &activity_to_route,
666 config,
667 None,
668 )
669 })
670 .collect();
671
672 info!(
673 "[Sections] Converted {} sections for {} in {}ms",
674 sport_sections.len(),
675 sport_type,
676 section_convert_start.elapsed().as_millis()
677 );
678
679 let fold_start = std::time::Instant::now();
681 let fold_sections = split_folding_sections(sport_sections, config);
682 info!(
683 "[Sections] After fold splitting: {} sections in {}ms",
684 fold_sections.len(),
685 fold_start.elapsed().as_millis()
686 );
687
688 let heading_start = std::time::Instant::now();
690 let heading_sections = split_at_heading_changes(fold_sections, config);
691 info!(
692 "[Sections] After heading splitting: {} sections in {}ms",
693 heading_sections.len(),
694 heading_start.elapsed().as_millis()
695 );
696
697 let gradient_start = std::time::Instant::now();
699 let gradient_sections = split_at_gradient_changes(heading_sections, config);
700 info!(
701 "[Sections] After gradient splitting: {} sections in {}ms",
702 gradient_sections.len(),
703 gradient_start.elapsed().as_millis()
704 );
705
706 let merge_start = std::time::Instant::now();
708 let merged_sections = merge_nearby_sections(gradient_sections, config);
709 info!(
710 "[Sections] After nearby merge: {} sections in {}ms",
711 merged_sections.len(),
712 merge_start.elapsed().as_millis()
713 );
714
715 let dedup_start = std::time::Instant::now();
717 let deduped_sections = remove_overlapping_sections(merged_sections, config);
718 info!(
719 "[Sections] After dedup: {} unique sections in {}ms",
720 deduped_sections.len(),
721 dedup_start.elapsed().as_millis()
722 );
723
724 let split_start = std::time::Instant::now();
727 let final_sections = split_high_variance_sections(deduped_sections, &track_map, config);
728 info!(
729 "[Sections] After density splitting: {} sections in {}ms",
730 final_sections.len(),
731 split_start.elapsed().as_millis()
732 );
733
734 for (i, mut section) in final_sections.into_iter().enumerate() {
736 section.id = format!("sec_{}_{}", sport_type.to_lowercase(), section_counter + i);
737 all_sections.push(section);
738 }
739 section_counter += all_sections.len();
740 }
741
742 all_sections.sort_by(|a, b| b.visit_count.cmp(&a.visit_count));
744
745 info!("[Sections] Detected {} total sections", all_sections.len());
746
747 all_sections
748}
749
750struct ScaleResult {
752 sections: Vec<FrequentSection>,
753 potentials: Vec<PotentialSection>,
754 overlaps_found: u32,
755 scale_name: String,
756}
757
758fn process_scale_preset(
760 preset: &ScalePreset,
761 tracks_by_sport: &HashMap<String, Vec<(&str, &[GpsPoint])>>,
762 track_map: &HashMap<String, Vec<GpsPoint>>,
763 activity_to_route: &HashMap<&str, &str>,
764 config: &SectionConfig,
765) -> ScaleResult {
766 info!(
767 "[MultiScale] Processing {} scale: {}-{}m, min {} activities",
768 preset.name, preset.min_length, preset.max_length, preset.min_activities
769 );
770
771 let scale_config = SectionConfig {
772 min_section_length: preset.min_length,
773 max_section_length: preset.max_length,
774 min_activities: preset.min_activities,
775 ..config.clone()
776 };
777
778 let mut scale_sections: Vec<FrequentSection> = Vec::new();
779 let mut scale_potentials: Vec<PotentialSection> = Vec::new();
780 let mut overlaps_found = 0u32;
781
782 for (sport_type, sport_tracks) in tracks_by_sport {
784 let min_tracks_for_processing = if config.include_potentials {
786 1
787 } else {
788 preset.min_activities as usize
789 };
790 if sport_tracks.len() < min_tracks_for_processing {
791 continue;
792 }
793
794 #[cfg(feature = "parallel")]
795 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
796 .par_iter()
797 .map(|(_, pts)| build_rtree(pts))
798 .collect();
799
800 #[cfg(not(feature = "parallel"))]
801 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
802 .iter()
803 .map(|(_, pts)| build_rtree(pts))
804 .collect();
805
806 let pairs: Vec<(usize, usize)> = (0..sport_tracks.len())
808 .flat_map(|i| ((i + 1)..sport_tracks.len()).map(move |j| (i, j)))
809 .collect();
810
811 #[cfg(feature = "parallel")]
812 let overlaps: Vec<FullTrackOverlap> = pairs
813 .into_par_iter()
814 .filter_map(|(i, j)| {
815 let (id_a, track_a) = sport_tracks[i];
816 let (id_b, track_b) = sport_tracks[j];
817 if !bounds_overlap_tracks(track_a, track_b, scale_config.proximity_threshold) {
818 return None;
819 }
820 find_full_track_overlap(id_a, track_a, id_b, track_b, &rtrees[j], &scale_config)
821 })
822 .collect();
823
824 #[cfg(not(feature = "parallel"))]
825 let overlaps: Vec<FullTrackOverlap> = pairs
826 .into_iter()
827 .filter_map(|(i, j)| {
828 let (id_a, track_a) = sport_tracks[i];
829 let (id_b, track_b) = sport_tracks[j];
830 if !bounds_overlap_tracks(track_a, track_b, scale_config.proximity_threshold) {
831 return None;
832 }
833 find_full_track_overlap(id_a, track_a, id_b, track_b, &rtrees[j], &scale_config)
834 })
835 .collect();
836
837 overlaps_found += overlaps.len() as u32;
838
839 let clusters = cluster_overlaps(overlaps, &scale_config);
841
842 let (significant, potential): (Vec<_>, Vec<_>) = clusters
844 .into_iter()
845 .partition(|c| c.activity_ids.len() >= preset.min_activities as usize);
846
847 #[cfg(feature = "parallel")]
849 let sport_sections: Vec<FrequentSection> = significant
850 .into_par_iter()
851 .enumerate()
852 .filter_map(|(idx, cluster)| {
853 process_cluster(
854 idx,
855 cluster,
856 sport_type,
857 track_map,
858 activity_to_route,
859 &scale_config,
860 Some(&preset.name),
861 )
862 })
863 .collect();
864
865 #[cfg(not(feature = "parallel"))]
866 let sport_sections: Vec<FrequentSection> = significant
867 .into_iter()
868 .enumerate()
869 .filter_map(|(idx, cluster)| {
870 process_cluster(
871 idx,
872 cluster,
873 sport_type,
874 track_map,
875 activity_to_route,
876 &scale_config,
877 Some(&preset.name),
878 )
879 })
880 .collect();
881
882 scale_sections.extend(sport_sections);
883
884 if config.include_potentials {
886 for (idx, cluster) in potential.into_iter().enumerate() {
887 let activity_count = cluster.activity_ids.len();
889 if activity_count >= 1
890 && activity_count < preset.min_activities as usize
891 && let Some((_rep_id, rep_polyline)) = Some(select_medoid(&cluster))
892 && !rep_polyline.is_empty()
893 {
894 let distance = calculate_route_distance(&rep_polyline);
895 if distance >= preset.min_length && distance <= preset.max_length {
896 scale_potentials.push(PotentialSection {
897 id: format!(
898 "pot_{}_{}_{}",
899 preset.name,
900 sport_type.to_lowercase(),
901 idx
902 ),
903 sport_type: sport_type.to_string(),
904 polyline: rep_polyline,
905 activity_ids: cluster.activity_ids.into_iter().collect(),
906 visit_count: activity_count as u32,
907 distance_meters: distance,
908 confidence: 0.3 + (activity_count as f64 * 0.2), scale: preset.name.clone(),
910 });
911 }
912 }
913 }
914 }
915 }
916
917 info!(
918 "[MultiScale] {} scale: {} sections, {} potentials",
919 preset.name,
920 scale_sections.len(),
921 scale_potentials.len()
922 );
923
924 ScaleResult {
925 sections: scale_sections,
926 potentials: scale_potentials,
927 overlaps_found,
928 scale_name: preset.name.clone(),
929 }
930}
931
932pub fn detect_sections_multiscale(
933 tracks: &[(String, Vec<GpsPoint>)],
934 sport_types: &HashMap<String, String>,
935 groups: &[RouteGroup],
936 config: &SectionConfig,
937) -> MultiScaleSectionResult {
938 info!(
939 "[MultiScale] Detecting from {} tracks with {} scale presets",
940 tracks.len(),
941 config.scale_presets.len()
942 );
943
944 let mut stats = DetectionStats {
945 activities_processed: tracks.len() as u32,
946 overlaps_found: 0,
947 sections_by_scale: HashMap::new(),
948 potentials_by_scale: HashMap::new(),
949 };
950
951 if config.scale_presets.is_empty() {
953 let sections = detect_sections_from_tracks(tracks, sport_types, groups, config);
954 stats
955 .sections_by_scale
956 .insert("legacy".to_string(), sections.len() as u32);
957 return MultiScaleSectionResult {
958 sections,
959 potentials: vec![],
960 stats,
961 };
962 }
963
964 let track_map: HashMap<String, Vec<GpsPoint>> = tracks
966 .iter()
967 .map(|(id, pts)| (id.clone(), pts.clone()))
968 .collect();
969
970 let significant_groups: Vec<&RouteGroup> = groups
971 .iter()
972 .filter(|g| g.activity_ids.len() >= 2)
973 .collect();
974
975 let activity_to_route: HashMap<&str, &str> = significant_groups
976 .iter()
977 .flat_map(|g| {
978 g.activity_ids
979 .iter()
980 .map(|aid| (aid.as_str(), g.group_id.as_str()))
981 })
982 .collect();
983
984 let mut tracks_by_sport: HashMap<String, Vec<(&str, &[GpsPoint])>> = HashMap::new();
986 for (activity_id, points) in tracks {
987 let sport = sport_types
988 .get(activity_id)
989 .cloned()
990 .unwrap_or_else(|| "Unknown".to_string());
991 tracks_by_sport
992 .entry(sport)
993 .or_default()
994 .push((activity_id.as_str(), points.as_slice()));
995 }
996
997 #[cfg(feature = "parallel")]
999 let scale_results: Vec<ScaleResult> = config
1000 .scale_presets
1001 .par_iter()
1002 .map(|preset| {
1003 process_scale_preset(
1004 preset,
1005 &tracks_by_sport,
1006 &track_map,
1007 &activity_to_route,
1008 config,
1009 )
1010 })
1011 .collect();
1012
1013 #[cfg(not(feature = "parallel"))]
1014 let scale_results: Vec<ScaleResult> = config
1015 .scale_presets
1016 .iter()
1017 .map(|preset| {
1018 process_scale_preset(
1019 preset,
1020 &tracks_by_sport,
1021 &track_map,
1022 &activity_to_route,
1023 config,
1024 )
1025 })
1026 .collect();
1027
1028 let mut all_sections: Vec<FrequentSection> = Vec::new();
1030 let mut all_potentials: Vec<PotentialSection> = Vec::new();
1031
1032 for result in scale_results {
1033 stats.overlaps_found += result.overlaps_found;
1034 stats
1035 .sections_by_scale
1036 .insert(result.scale_name.clone(), result.sections.len() as u32);
1037 stats
1038 .potentials_by_scale
1039 .insert(result.scale_name, result.potentials.len() as u32);
1040 all_sections.extend(result.sections);
1041 all_potentials.extend(result.potentials);
1042 }
1043
1044 let fold_start = std::time::Instant::now();
1046 let fold_sections = split_folding_sections(all_sections, config);
1047 info!(
1048 "[MultiScale] After fold splitting: {} sections in {}ms",
1049 fold_sections.len(),
1050 fold_start.elapsed().as_millis()
1051 );
1052
1053 let heading_start = std::time::Instant::now();
1055 let heading_sections = split_at_heading_changes(fold_sections, config);
1056 info!(
1057 "[MultiScale] After heading splitting: {} sections in {}ms",
1058 heading_sections.len(),
1059 heading_start.elapsed().as_millis()
1060 );
1061
1062 let gradient_start = std::time::Instant::now();
1064 let gradient_sections = split_at_gradient_changes(heading_sections, config);
1065 info!(
1066 "[MultiScale] After gradient splitting: {} sections in {}ms",
1067 gradient_sections.len(),
1068 gradient_start.elapsed().as_millis()
1069 );
1070
1071 let merge_start = std::time::Instant::now();
1072 let merged_sections = merge_nearby_sections(gradient_sections, config);
1073 info!(
1074 "[MultiScale] After nearby merge: {} sections in {}ms",
1075 merged_sections.len(),
1076 merge_start.elapsed().as_millis()
1077 );
1078
1079 let dedup_start = std::time::Instant::now();
1081 let deduped_sections = if config.preserve_hierarchy {
1082 remove_overlapping_sections_hierarchical(merged_sections, config)
1083 } else {
1084 remove_overlapping_sections(merged_sections, config)
1085 };
1086 info!(
1087 "[MultiScale] After dedup: {} sections in {}ms",
1088 deduped_sections.len(),
1089 dedup_start.elapsed().as_millis()
1090 );
1091
1092 let split_start = std::time::Instant::now();
1093 let final_sections = split_high_variance_sections(deduped_sections, &track_map, config);
1094 info!(
1095 "[MultiScale] After density splitting: {} sections in {}ms",
1096 final_sections.len(),
1097 split_start.elapsed().as_millis()
1098 );
1099
1100 let mut sorted_sections = final_sections;
1102 sorted_sections.sort_by(|a, b| b.visit_count.cmp(&a.visit_count));
1103
1104 let mut sorted_potentials = all_potentials;
1106 sorted_potentials.sort_by(|a, b| {
1107 b.confidence
1108 .partial_cmp(&a.confidence)
1109 .unwrap_or(std::cmp::Ordering::Equal)
1110 });
1111
1112 info!(
1113 "[MultiScale] Final: {} sections, {} potentials",
1114 sorted_sections.len(),
1115 sorted_potentials.len()
1116 );
1117
1118 MultiScaleSectionResult {
1119 sections: sorted_sections,
1120 potentials: sorted_potentials,
1121 stats,
1122 }
1123}
1124
1125#[cfg(feature = "persistence")]
1128pub fn detect_sections_multiscale_with_progress(
1129 tracks: &[(String, Vec<GpsPoint>)],
1130 sport_types: &HashMap<String, String>,
1131 groups: &[RouteGroup],
1132 config: &SectionConfig,
1133 progress: &SectionDetectionProgress,
1134) -> MultiScaleSectionResult {
1135 info!(
1136 "[MultiScale] Detecting from {} tracks with {} scale presets (with progress)",
1137 tracks.len(),
1138 config.scale_presets.len()
1139 );
1140
1141 let mut all_sections: Vec<FrequentSection> = Vec::new();
1142 let mut all_potentials: Vec<PotentialSection> = Vec::new();
1143 let mut stats = DetectionStats {
1144 activities_processed: tracks.len() as u32,
1145 overlaps_found: 0,
1146 sections_by_scale: HashMap::new(),
1147 potentials_by_scale: HashMap::new(),
1148 };
1149
1150 if config.scale_presets.is_empty() {
1152 progress.set_phase("finding_overlaps", tracks.len() as u32);
1153 let sections = detect_sections_from_tracks(tracks, sport_types, groups, config);
1154 stats
1155 .sections_by_scale
1156 .insert("legacy".to_string(), sections.len() as u32);
1157 progress.set_phase("complete", 0);
1158 return MultiScaleSectionResult {
1159 sections,
1160 potentials: vec![],
1161 stats,
1162 };
1163 }
1164
1165 let mut tracks_by_sport: HashMap<String, Vec<(&str, &[GpsPoint])>> = HashMap::new();
1167 for (activity_id, points) in tracks {
1168 let sport = sport_types
1169 .get(activity_id)
1170 .cloned()
1171 .unwrap_or_else(|| "Unknown".to_string());
1172 tracks_by_sport
1173 .entry(sport)
1174 .or_default()
1175 .push((activity_id.as_str(), points.as_slice()));
1176 }
1177
1178 let track_map: HashMap<String, Vec<GpsPoint>> = tracks
1180 .iter()
1181 .map(|(id, pts)| (id.clone(), pts.clone()))
1182 .collect();
1183
1184 let significant_groups: Vec<&RouteGroup> = groups
1185 .iter()
1186 .filter(|g| g.activity_ids.len() >= 2)
1187 .collect();
1188
1189 let activity_to_route: HashMap<&str, &str> = significant_groups
1190 .iter()
1191 .flat_map(|g| {
1192 g.activity_ids
1193 .iter()
1194 .map(|aid| (aid.as_str(), g.group_id.as_str()))
1195 })
1196 .collect();
1197
1198 let total_presets = config.scale_presets.len();
1199 let mut section_counter = 0;
1200
1201 for (preset_idx, preset) in config.scale_presets.iter().enumerate() {
1203 progress.set_phase(&format!("scale_{}", preset.name), total_presets as u32);
1204 progress
1205 .completed
1206 .store(preset_idx as u32, std::sync::atomic::Ordering::Relaxed);
1207
1208 info!(
1209 "[MultiScale] Processing {} scale ({}-{}m)",
1210 preset.name, preset.min_length, preset.max_length
1211 );
1212
1213 let mut scale_config = config.clone();
1215 scale_config.min_section_length = preset.min_length;
1216 scale_config.max_section_length = preset.max_length;
1217 scale_config.min_activities = preset.min_activities;
1218
1219 let mut scale_sections = 0u32;
1220 let mut scale_potentials = 0u32;
1221
1222 for (sport_type, sport_tracks) in &tracks_by_sport {
1224 let min_tracks_for_processing = if config.include_potentials {
1225 1
1226 } else {
1227 preset.min_activities as usize
1228 };
1229 if sport_tracks.len() < min_tracks_for_processing {
1230 continue;
1231 }
1232
1233 progress.set_phase("building_rtrees", sport_tracks.len() as u32);
1235
1236 #[cfg(feature = "parallel")]
1237 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
1238 .par_iter()
1239 .map(|(_, pts)| build_rtree(pts))
1240 .collect();
1241
1242 #[cfg(not(feature = "parallel"))]
1243 let rtrees: Vec<rstar::RTree<IndexedPoint>> = sport_tracks
1244 .iter()
1245 .enumerate()
1246 .map(|(i, (_, pts))| {
1247 progress
1248 .completed
1249 .store(i as u32, std::sync::atomic::Ordering::Relaxed);
1250 build_rtree(pts)
1251 })
1252 .collect();
1253
1254 let pairs: Vec<(usize, usize)> = (0..sport_tracks.len())
1256 .flat_map(|i| ((i + 1)..sport_tracks.len()).map(move |j| (i, j)))
1257 .collect();
1258
1259 let total_pairs = pairs.len() as u32;
1260 progress.set_phase("finding_overlaps", total_pairs);
1261
1262 #[cfg(feature = "parallel")]
1263 let overlaps: Vec<FullTrackOverlap> = {
1264 use std::sync::atomic::AtomicU32;
1265 let counter = AtomicU32::new(0);
1266
1267 pairs
1268 .into_par_iter()
1269 .filter_map(|(i, j)| {
1270 let count = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1271 if count.is_multiple_of(100) {
1272 progress
1273 .completed
1274 .store(count, std::sync::atomic::Ordering::Relaxed);
1275 }
1276
1277 let (id_a, track_a) = sport_tracks[i];
1278 let (id_b, track_b) = sport_tracks[j];
1279 if !bounds_overlap_tracks(
1280 track_a,
1281 track_b,
1282 scale_config.proximity_threshold,
1283 ) {
1284 return None;
1285 }
1286 find_full_track_overlap(
1287 id_a,
1288 track_a,
1289 id_b,
1290 track_b,
1291 &rtrees[j],
1292 &scale_config,
1293 )
1294 })
1295 .collect()
1296 };
1297
1298 #[cfg(not(feature = "parallel"))]
1299 let overlaps: Vec<FullTrackOverlap> = pairs
1300 .into_iter()
1301 .enumerate()
1302 .filter_map(|(idx, (i, j))| {
1303 if idx % 50 == 0 {
1304 progress
1305 .completed
1306 .store(idx as u32, std::sync::atomic::Ordering::Relaxed);
1307 }
1308
1309 let (id_a, track_a) = sport_tracks[i];
1310 let (id_b, track_b) = sport_tracks[j];
1311 if !bounds_overlap_tracks(track_a, track_b, scale_config.proximity_threshold) {
1312 return None;
1313 }
1314 find_full_track_overlap(id_a, track_a, id_b, track_b, &rtrees[j], &scale_config)
1315 })
1316 .collect();
1317
1318 stats.overlaps_found += overlaps.len() as u32;
1319
1320 progress.set_phase("clustering", overlaps.len() as u32);
1322 let clusters = cluster_overlaps(overlaps, &scale_config);
1323
1324 let (significant, potential): (Vec<_>, Vec<_>) = clusters
1326 .into_iter()
1327 .partition(|c| c.activity_ids.len() >= preset.min_activities as usize);
1328
1329 let cluster_count = significant.len() as u32;
1331 progress.set_phase("building_sections", cluster_count);
1332
1333 #[cfg(feature = "parallel")]
1334 let sport_sections_vec: Vec<FrequentSection> = {
1335 use std::sync::atomic::AtomicU32;
1336 let counter = AtomicU32::new(0);
1337
1338 significant
1339 .into_par_iter()
1340 .enumerate()
1341 .filter_map(|(idx, cluster)| {
1342 let count = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1343 progress
1344 .completed
1345 .store(count, std::sync::atomic::Ordering::Relaxed);
1346
1347 process_cluster(
1348 idx,
1349 cluster,
1350 sport_type,
1351 &track_map,
1352 &activity_to_route,
1353 &scale_config,
1354 Some(&preset.name),
1355 )
1356 })
1357 .collect()
1358 };
1359
1360 #[cfg(not(feature = "parallel"))]
1361 let sport_sections_vec: Vec<FrequentSection> = significant
1362 .into_iter()
1363 .enumerate()
1364 .filter_map(|(idx, cluster)| {
1365 progress
1366 .completed
1367 .store(idx as u32, std::sync::atomic::Ordering::Relaxed);
1368 process_cluster(
1369 idx,
1370 cluster,
1371 sport_type,
1372 &track_map,
1373 &activity_to_route,
1374 &scale_config,
1375 Some(&preset.name),
1376 )
1377 })
1378 .collect();
1379
1380 scale_sections += sport_sections_vec.len() as u32;
1381
1382 for (i, mut section) in sport_sections_vec.into_iter().enumerate() {
1384 section.id = format!(
1385 "sec_{}_{}_{}_{}",
1386 sport_type.to_lowercase(),
1387 preset.name,
1388 section_counter,
1389 i
1390 );
1391 all_sections.push(section);
1392 }
1393 section_counter += 1;
1394
1395 if config.include_potentials {
1397 for (idx, cluster) in potential.into_iter().enumerate() {
1398 let activity_count = cluster.activity_ids.len();
1400 if activity_count >= 1
1401 && activity_count < preset.min_activities as usize
1402 && let Some((_rep_id, rep_polyline)) = Some(select_medoid(&cluster))
1403 && !rep_polyline.is_empty()
1404 {
1405 let distance = calculate_route_distance(&rep_polyline);
1406 if distance >= preset.min_length && distance <= preset.max_length {
1407 all_potentials.push(PotentialSection {
1408 id: format!(
1409 "pot_{}_{}_{}",
1410 preset.name,
1411 sport_type.to_lowercase(),
1412 idx
1413 ),
1414 sport_type: sport_type.to_string(),
1415 polyline: rep_polyline,
1416 activity_ids: cluster.activity_ids.into_iter().collect(),
1417 visit_count: activity_count as u32,
1418 distance_meters: distance,
1419 confidence: 0.3 + (activity_count as f64 * 0.2),
1420 scale: preset.name.clone(),
1421 });
1422 scale_potentials += 1;
1423 }
1424 }
1425 }
1426 }
1427 }
1428
1429 stats
1430 .sections_by_scale
1431 .insert(preset.name.clone(), scale_sections);
1432 stats
1433 .potentials_by_scale
1434 .insert(preset.name.clone(), scale_potentials);
1435 }
1436
1437 progress.set_phase("postprocessing", 4);
1439
1440 let fold_start = std::time::Instant::now();
1441 let split_sections = split_folding_sections(all_sections, config);
1442 progress.increment();
1443 info!(
1444 "[MultiScale] After fold splitting: {} sections in {}ms",
1445 split_sections.len(),
1446 fold_start.elapsed().as_millis()
1447 );
1448
1449 let merge_start = std::time::Instant::now();
1450 let merged_sections = merge_nearby_sections(split_sections, config);
1451 progress.increment();
1452 info!(
1453 "[MultiScale] After nearby merge: {} sections in {}ms",
1454 merged_sections.len(),
1455 merge_start.elapsed().as_millis()
1456 );
1457
1458 let dedup_start = std::time::Instant::now();
1459 let deduped_sections = remove_overlapping_sections_hierarchical(merged_sections, config);
1460 progress.increment();
1461 info!(
1462 "[MultiScale] After hierarchical dedup: {} sections in {}ms",
1463 deduped_sections.len(),
1464 dedup_start.elapsed().as_millis()
1465 );
1466
1467 let split_start = std::time::Instant::now();
1468 let final_sections = split_high_variance_sections(deduped_sections, &track_map, config);
1469 progress.increment();
1470 info!(
1471 "[MultiScale] After density splitting: {} sections in {}ms",
1472 final_sections.len(),
1473 split_start.elapsed().as_millis()
1474 );
1475
1476 let mut sorted_sections = final_sections;
1478 sorted_sections.sort_by(|a, b| b.visit_count.cmp(&a.visit_count));
1479
1480 let mut sorted_potentials = all_potentials;
1482 sorted_potentials.sort_by(|a, b| {
1483 b.confidence
1484 .partial_cmp(&a.confidence)
1485 .unwrap_or(std::cmp::Ordering::Equal)
1486 });
1487
1488 info!(
1489 "[MultiScale] Final: {} sections, {} potentials",
1490 sorted_sections.len(),
1491 sorted_potentials.len()
1492 );
1493
1494 progress.set_phase("complete", 0);
1495
1496 MultiScaleSectionResult {
1497 sections: sorted_sections,
1498 potentials: sorted_potentials,
1499 stats,
1500 }
1501}
1502
1503fn remove_overlapping_sections_hierarchical(
1506 mut sections: Vec<FrequentSection>,
1507 config: &SectionConfig,
1508) -> Vec<FrequentSection> {
1509 if sections.len() <= 1 {
1510 return sections;
1511 }
1512
1513 sections.sort_by(|a, b| {
1515 b.distance_meters
1516 .partial_cmp(&a.distance_meters)
1517 .unwrap_or(std::cmp::Ordering::Equal)
1518 });
1519
1520 #[cfg(feature = "parallel")]
1522 let rtrees: Vec<rstar::RTree<IndexedPoint>> = {
1523 use rayon::prelude::*;
1524 sections
1525 .par_iter()
1526 .map(|s| build_rtree(&s.polyline))
1527 .collect()
1528 };
1529
1530 #[cfg(not(feature = "parallel"))]
1531 let rtrees: Vec<rstar::RTree<IndexedPoint>> =
1532 sections.iter().map(|s| build_rtree(&s.polyline)).collect();
1533
1534 let mut keep = vec![true; sections.len()];
1535
1536 for i in 0..sections.len() {
1537 if !keep[i] {
1538 continue;
1539 }
1540
1541 let tree_i = &rtrees[i]; for j in (i + 1)..sections.len() {
1544 if !keep[j] {
1545 continue;
1546 }
1547
1548 let containment = compute_polyline_containment_with_rtree(
1550 §ions[j].polyline,
1551 tree_i,
1552 config.proximity_threshold,
1553 );
1554
1555 let length_ratio = sections[j].distance_meters / sections[i].distance_meters;
1557
1558 let same_scale = match (§ions[i].scale, §ions[j].scale) {
1563 (Some(a), Some(b)) => a == b,
1564 _ => true, };
1566
1567 if containment > 0.9 && length_ratio > 0.7 && same_scale {
1568 keep[j] = false;
1569 }
1570 }
1571 }
1572
1573 sections
1574 .into_iter()
1575 .zip(keep)
1576 .filter_map(|(s, k)| if k { Some(s) } else { None })
1577 .collect()
1578}
1579
1580fn compute_polyline_containment_with_rtree(
1583 polyline_a: &[GpsPoint],
1584 tree_b: &rstar::RTree<IndexedPoint>,
1585 proximity_threshold: f64,
1586) -> f64 {
1587 use rstar::PointDistance;
1588
1589 if polyline_a.is_empty() {
1590 return 0.0;
1591 }
1592
1593 let threshold_deg = proximity_threshold / 111_000.0;
1594 let threshold_deg_sq = threshold_deg * threshold_deg;
1595
1596 let mut contained_count = 0;
1597 for point_a in polyline_a {
1598 let query = [point_a.latitude, point_a.longitude];
1599 if let Some(nearest) = tree_b.nearest_neighbor(&query)
1600 && nearest.distance_2(&query) <= threshold_deg_sq
1601 {
1602 contained_count += 1;
1603 }
1604 }
1605
1606 contained_count as f64 / polyline_a.len() as f64
1607}
1608
1609#[allow(dead_code)]
1612fn compute_polyline_containment(
1613 polyline_a: &[GpsPoint],
1614 polyline_b: &[GpsPoint],
1615 proximity_threshold: f64,
1616) -> f64 {
1617 use crate::geo_utils::haversine_distance;
1618
1619 if polyline_a.is_empty() || polyline_b.is_empty() {
1620 return 0.0;
1621 }
1622
1623 let mut contained_count = 0;
1624 for point_a in polyline_a {
1625 let min_dist = polyline_b
1626 .iter()
1627 .map(|point_b| haversine_distance(point_a, point_b))
1628 .fold(f64::MAX, |a, b| a.min(b));
1629
1630 if min_dist <= proximity_threshold {
1631 contained_count += 1;
1632 }
1633 }
1634
1635 contained_count as f64 / polyline_a.len() as f64
1636}