use super::{
FrequentSection, SectionConfig, SectionPortion, compute_consensus_polyline,
compute_initial_stability,
};
use crate::GpsPoint;
use crate::geo_utils::haversine_distance;
use crate::matching::calculate_route_distance;
use log::info;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SectionUpdateResult {
pub section: FrequentSection,
pub was_modified: bool,
pub new_activities_added: u32,
pub confidence_delta: f64,
pub stability_delta: f64,
}
pub fn update_section_with_new_traces(
section: &FrequentSection,
new_traces: &HashMap<String, Vec<GpsPoint>>,
config: &SectionConfig,
timestamp: Option<String>,
) -> SectionUpdateResult {
if section.is_user_defined {
return SectionUpdateResult {
section: section.clone(),
was_modified: false,
new_activities_added: 0,
confidence_delta: 0.0,
stability_delta: 0.0,
};
}
let existing_activity_ids: std::collections::HashSet<_> =
section.activity_ids.iter().cloned().collect();
let relevant_traces: HashMap<String, Vec<GpsPoint>> = new_traces
.iter()
.filter(|(id, _)| !existing_activity_ids.contains(*id))
.filter(|(_, trace)| is_trace_near_section(trace, §ion.polyline, config))
.map(|(id, trace)| (id.clone(), trace.clone()))
.collect();
if relevant_traces.is_empty() {
return SectionUpdateResult {
section: section.clone(),
was_modified: false,
new_activities_added: 0,
confidence_delta: 0.0,
stability_delta: 0.0,
};
}
info!(
"[Evolution] Updating section {} with {} new traces",
section.id,
relevant_traces.len()
);
let mut full_track_map = section.activity_traces.clone();
for (id, trace) in &relevant_traces {
let extracted =
extract_trace_near_section(trace, §ion.polyline, config.proximity_threshold);
if !extracted.is_empty() {
full_track_map.insert(id.clone(), extracted);
}
}
let all_traces: Vec<Vec<GpsPoint>> = full_track_map.values().cloned().collect();
let blend_factor = 1.0 - (section.stability * 0.7);
let new_consensus = compute_weighted_consensus(
§ion.polyline,
&all_traces,
config.proximity_threshold,
blend_factor,
);
let mut updated = section.clone();
let old_confidence = section.confidence;
let old_stability = section.stability;
updated.polyline = blend_polylines(§ion.polyline, &new_consensus.polyline, blend_factor);
updated.activity_ids.extend(relevant_traces.keys().cloned());
updated.activity_ids.sort();
updated.activity_ids.dedup();
updated.activity_traces = full_track_map;
updated.visit_count = updated.activity_ids.len() as u32;
updated.observation_count = all_traces.len() as u32;
updated.distance_meters = calculate_route_distance(&updated.polyline);
updated.confidence = new_consensus.confidence;
updated.average_spread = new_consensus.average_spread;
updated.point_density = new_consensus.point_density;
updated.version += 1;
updated.updated_at = timestamp;
updated.stability = compute_initial_stability(
updated.observation_count,
updated.average_spread,
config.proximity_threshold,
);
for (activity_id, trace) in &relevant_traces {
if let Some(portion) = compute_portion_for_trace(activity_id, trace, &updated.polyline) {
updated.activity_portions.push(portion);
}
}
SectionUpdateResult {
section: updated.clone(),
was_modified: true,
new_activities_added: relevant_traces.len() as u32,
confidence_delta: updated.confidence - old_confidence,
stability_delta: updated.stability - old_stability,
}
}
pub fn merge_overlapping_sections(
sections: &[FrequentSection],
config: &SectionConfig,
new_id: String,
timestamp: Option<String>,
) -> Option<FrequentSection> {
if sections.is_empty() {
return None;
}
if sections.len() == 1 {
return Some(sections[0].clone());
}
if sections.iter().any(|s| s.is_user_defined) {
info!("[Evolution] Cannot merge: contains user-defined sections");
return None;
}
let sport_type = §ions[0].sport_type;
if !sections.iter().all(|s| &s.sport_type == sport_type) {
info!("[Evolution] Cannot merge: different sport types");
return None;
}
info!(
"[Evolution] Merging {} sections into {}",
sections.len(),
new_id
);
let mut all_activity_ids: Vec<String> = sections
.iter()
.flat_map(|s| s.activity_ids.iter().cloned())
.collect();
all_activity_ids.sort();
all_activity_ids.dedup();
let mut all_traces: HashMap<String, Vec<GpsPoint>> = HashMap::new();
for section in sections {
for (id, trace) in §ion.activity_traces {
if let Some(existing) = all_traces.get(id) {
if trace.len() > existing.len() {
all_traces.insert(id.clone(), trace.clone());
}
} else {
all_traces.insert(id.clone(), trace.clone());
}
}
}
let mut all_route_ids: Vec<String> = sections
.iter()
.flat_map(|s| s.route_ids.iter().cloned())
.collect();
all_route_ids.sort();
all_route_ids.dedup();
let reference_section = sections
.iter()
.max_by(|a, b| {
a.distance_meters
.partial_cmp(&b.distance_meters)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap();
let trace_list: Vec<Vec<GpsPoint>> = all_traces.values().cloned().collect();
let consensus = compute_consensus_polyline(
&reference_section.polyline,
&trace_list,
config.proximity_threshold,
);
let mut all_portions: Vec<SectionPortion> = Vec::new();
for section in sections {
all_portions.extend(section.activity_portions.iter().cloned());
}
let mut seen_activities = std::collections::HashSet::new();
all_portions.retain(|p| seen_activities.insert(p.activity_id.clone()));
let stability = compute_initial_stability(
consensus.observation_count,
consensus.average_spread,
config.proximity_threshold,
);
Some(FrequentSection {
id: new_id,
name: None, sport_type: sport_type.clone(),
polyline: consensus.polyline,
representative_activity_id: reference_section.representative_activity_id.clone(),
activity_ids: all_activity_ids.clone(),
activity_portions: all_portions,
route_ids: all_route_ids,
visit_count: all_activity_ids.len() as u32,
distance_meters: calculate_route_distance(&reference_section.polyline),
activity_traces: all_traces,
confidence: consensus.confidence,
observation_count: consensus.observation_count,
average_spread: consensus.average_spread,
point_density: consensus.point_density,
scale: reference_section.scale.clone(),
version: 1,
is_user_defined: false,
created_at: timestamp,
updated_at: None,
stability,
})
}
fn is_trace_near_section(
trace: &[GpsPoint],
section_polyline: &[GpsPoint],
config: &SectionConfig,
) -> bool {
if trace.is_empty() || section_polyline.is_empty() {
return false;
}
let threshold = config.proximity_threshold * 1.5;
let sample_step = (section_polyline.len() / 10).max(1);
let mut near_count = 0;
let mut samples_checked = 0;
for (i, section_point) in section_polyline.iter().enumerate() {
if i % sample_step != 0 {
continue;
}
samples_checked += 1;
for trace_point in trace {
let dist = haversine_distance(section_point, trace_point);
if dist <= threshold {
near_count += 1;
break;
}
}
}
samples_checked > 0 && (near_count as f64 / samples_checked as f64) >= 0.6
}
fn extract_trace_near_section(
trace: &[GpsPoint],
section_polyline: &[GpsPoint],
proximity_threshold: f64,
) -> Vec<GpsPoint> {
let threshold = proximity_threshold * 1.2;
trace
.iter()
.filter(|point| {
section_polyline
.iter()
.any(|sp| haversine_distance(point, sp) <= threshold)
})
.cloned()
.collect()
}
fn compute_weighted_consensus(
reference: &[GpsPoint],
traces: &[Vec<GpsPoint>],
proximity_threshold: f64,
blend_factor: f64,
) -> super::consensus::ConsensusResult {
let _ = blend_factor; compute_consensus_polyline(reference, traces, proximity_threshold)
}
fn blend_polylines(
old_polyline: &[GpsPoint],
new_polyline: &[GpsPoint],
factor: f64,
) -> Vec<GpsPoint> {
if factor >= 1.0 {
return new_polyline.to_vec();
}
if factor <= 0.0 {
return old_polyline.to_vec();
}
let factor = factor.clamp(0.0, 1.0);
let mut blended = Vec::with_capacity(new_polyline.len());
for (i, new_point) in new_polyline.iter().enumerate() {
let normalized_pos = i as f64 / (new_polyline.len().max(1) - 1).max(1) as f64;
let old_idx = (normalized_pos * (old_polyline.len().max(1) - 1) as f64).round() as usize;
let old_idx = old_idx.min(old_polyline.len().saturating_sub(1));
if old_idx < old_polyline.len() {
let old_point = &old_polyline[old_idx];
let blended_lat = old_point.latitude * (1.0 - factor) + new_point.latitude * factor;
let blended_lng = old_point.longitude * (1.0 - factor) + new_point.longitude * factor;
blended.push(GpsPoint::new(blended_lat, blended_lng));
} else {
blended.push(*new_point);
}
}
blended
}
fn compute_portion_for_trace(
activity_id: &str,
trace: &[GpsPoint],
_section_polyline: &[GpsPoint],
) -> Option<SectionPortion> {
if trace.is_empty() {
return None;
}
let distance = calculate_route_distance(trace);
Some(SectionPortion {
activity_id: activity_id.to_string(),
start_index: 0, end_index: trace.len() as u32,
distance_meters: distance,
direction: "same".to_string(), })
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_section() -> FrequentSection {
FrequentSection {
id: "test_sec".to_string(),
name: None,
sport_type: "Run".to_string(),
polyline: vec![GpsPoint::new(46.23, 7.36), GpsPoint::new(46.24, 7.37)],
representative_activity_id: "act1".to_string(),
activity_ids: vec!["act1".to_string()],
activity_portions: vec![],
route_ids: vec![],
visit_count: 1,
distance_meters: 1000.0,
activity_traces: HashMap::new(),
confidence: 0.5,
observation_count: 1,
average_spread: 25.0,
point_density: vec![1, 1],
scale: Some("medium".to_string()),
version: 1,
is_user_defined: false,
created_at: None,
updated_at: None,
stability: 0.3,
}
}
#[test]
fn test_user_defined_not_modified() {
let mut section = make_test_section();
section.is_user_defined = true;
let new_traces = HashMap::new();
let config = SectionConfig::default();
let result = update_section_with_new_traces(§ion, &new_traces, &config, None);
assert!(!result.was_modified);
assert_eq!(result.new_activities_added, 0);
}
#[test]
fn test_empty_traces_not_modified() {
let section = make_test_section();
let new_traces = HashMap::new();
let config = SectionConfig::default();
let result = update_section_with_new_traces(§ion, &new_traces, &config, None);
assert!(!result.was_modified);
}
#[test]
fn test_blend_polylines_extremes() {
let old = vec![GpsPoint::new(0.0, 0.0), GpsPoint::new(1.0, 1.0)];
let new = vec![GpsPoint::new(2.0, 2.0), GpsPoint::new(3.0, 3.0)];
let result = blend_polylines(&old, &new, 0.0);
assert!((result[0].latitude - 0.0).abs() < 0.001);
let result = blend_polylines(&old, &new, 1.0);
assert!((result[0].latitude - 2.0).abs() < 0.001);
let result = blend_polylines(&old, &new, 0.5);
assert!((result[0].latitude - 1.0).abs() < 0.001);
}
}