use crate::errors::ProjectionError;
use crate::models::{DetectionRecord, ResolvedAnchor, TrainPath};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PathCalculationMode {
TopologyBased,
FallbackIndependent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathResult {
pub path: Option<TrainPath>,
pub mode: PathCalculationMode,
pub projected_positions: Vec<crate::models::ProjectedPosition>,
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debug_info: Option<DebugInfo>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub detection_provenance: Vec<DetectionRecord>,
}
impl PathResult {
pub fn new(
path: Option<TrainPath>,
mode: PathCalculationMode,
projected_positions: Vec<crate::models::ProjectedPosition>,
warnings: Vec<String>,
) -> Self {
Self {
path,
mode,
projected_positions,
warnings,
debug_info: None,
detection_provenance: Vec::new(),
}
}
pub fn with_debug_info(
path: Option<TrainPath>,
mode: PathCalculationMode,
projected_positions: Vec<crate::models::ProjectedPosition>,
warnings: Vec<String>,
debug_info: DebugInfo,
) -> Self {
Self {
path,
mode,
projected_positions,
warnings,
debug_info: Some(debug_info),
detection_provenance: Vec::new(),
}
}
pub fn has_debug_info(&self) -> bool {
self.debug_info.is_some()
}
pub fn is_topology_based(&self) -> bool {
self.mode == PathCalculationMode::TopologyBased
}
pub fn is_fallback(&self) -> bool {
self.mode == PathCalculationMode::FallbackIndependent
}
pub fn has_path(&self) -> bool {
self.path.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DebugInfo {
pub candidate_paths: Vec<CandidatePath>,
pub position_candidates: Vec<PositionCandidates>,
pub decision_tree: Vec<PathDecision>,
pub netelement_probabilities: Vec<NetelementProbabilityInfo>,
pub transition_probabilities: Vec<TransitionProbabilityEntry>,
pub sanity_decisions: Vec<viterbi::SanityDecision>,
pub gap_fills: Vec<viterbi::GapFill>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetelementProbabilityInfo {
pub netelement_id: String,
pub avg_emission_probability: f64,
pub position_count: usize,
pub geometry_coords: Vec<Vec<f64>>,
pub in_viterbi_path: bool,
pub is_bridge: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CandidatePath {
pub id: String,
pub direction: String,
pub segment_ids: Vec<String>,
pub segment_probabilities: Vec<f64>,
pub probability: f64,
pub selected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionCandidates {
pub position_index: usize,
pub timestamp: String,
pub coordinates: (f64, f64),
pub candidates: Vec<CandidateInfo>,
pub selected_netelement: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CandidateInfo {
pub netelement_id: String,
pub distance: f64,
pub heading_difference: Option<f64>,
pub distance_probability: f64,
pub heading_probability: Option<f64>,
pub combined_probability: f64,
pub status: String,
pub projected_lat: f64,
pub projected_lon: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TransitionProbabilityEntry {
pub from_step: usize,
pub to_step: usize,
pub from_netelement_id: String,
pub to_netelement_id: String,
pub transition_probability: f64,
pub is_viterbi_chosen: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathDecision {
pub step: usize,
pub decision_type: String,
pub current_segment: String,
pub options: Vec<String>,
pub option_probabilities: Vec<f64>,
pub chosen_option: String,
pub reason: String,
}
impl DebugInfo {
pub fn new() -> Self {
Self::default()
}
pub fn add_candidate_path(&mut self, path: CandidatePath) {
self.candidate_paths.push(path);
}
pub fn add_position_candidates(&mut self, candidates: PositionCandidates) {
self.position_candidates.push(candidates);
}
pub fn add_decision(&mut self, decision: PathDecision) {
self.decision_tree.push(decision);
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn is_empty(&self) -> bool {
self.candidate_paths.is_empty()
&& self.position_candidates.is_empty()
&& self.decision_tree.is_empty()
&& self.netelement_probabilities.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathConfig {
pub distance_scale: f64,
pub heading_scale: f64,
pub cutoff_distance: f64,
pub heading_cutoff: f64,
pub probability_threshold: f64,
pub resampling_distance: Option<f64>,
pub max_candidates: usize,
pub path_only: bool,
pub debug_mode: bool,
pub beta: f64,
pub edge_zone_distance: f64,
pub turn_scale: f64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<ResolvedAnchor>,
#[serde(default = "default_detection_cutoff_distance")]
pub detection_cutoff_distance: f64,
}
fn default_detection_cutoff_distance() -> f64 {
2.5
}
impl PathConfig {
#[allow(clippy::too_many_arguments)]
pub fn new(
distance_scale: f64,
heading_scale: f64,
cutoff_distance: f64,
heading_cutoff: f64,
probability_threshold: f64,
resampling_distance: Option<f64>,
max_candidates: usize,
path_only: bool,
debug_mode: bool,
beta: f64,
edge_zone_distance: f64,
turn_scale: f64,
) -> Result<Self, ProjectionError> {
let config = Self {
distance_scale,
heading_scale,
cutoff_distance,
heading_cutoff,
probability_threshold,
resampling_distance,
max_candidates,
path_only,
debug_mode,
beta,
edge_zone_distance,
turn_scale,
anchors: Vec::new(),
detection_cutoff_distance: default_detection_cutoff_distance(),
};
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), ProjectionError> {
if self.distance_scale <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"distance_scale must be positive".to_string(),
));
}
if self.heading_scale <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"heading_scale must be positive".to_string(),
));
}
if self.cutoff_distance <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"cutoff_distance must be positive".to_string(),
));
}
if !(0.0..=90.0).contains(&self.heading_cutoff) {
return Err(ProjectionError::InvalidGeometry(
"heading_cutoff must be in [0, 90]".to_string(),
));
}
if !(0.0..=1.0).contains(&self.probability_threshold) {
return Err(ProjectionError::InvalidGeometry(
"probability_threshold must be in [0, 1]".to_string(),
));
}
if let Some(resampling) = self.resampling_distance {
if resampling <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"resampling_distance must be positive".to_string(),
));
}
}
if self.max_candidates == 0 {
return Err(ProjectionError::InvalidGeometry(
"max_candidates must be at least 1".to_string(),
));
}
if self.beta <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"beta must be positive".to_string(),
));
}
if self.edge_zone_distance <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"edge_zone_distance must be positive".to_string(),
));
}
if self.turn_scale <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"turn_scale must be positive".to_string(),
));
}
if self.detection_cutoff_distance <= 0.0 {
return Err(ProjectionError::InvalidGeometry(
"detection_cutoff_distance must be positive".to_string(),
));
}
Ok(())
}
pub fn builder() -> PathConfigBuilder {
PathConfigBuilder::default()
}
}
impl Default for PathConfig {
fn default() -> Self {
Self {
distance_scale: 10.0,
heading_scale: 2.0,
cutoff_distance: 500.0,
heading_cutoff: 10.0,
probability_threshold: 0.02,
resampling_distance: None,
max_candidates: 3,
path_only: false,
debug_mode: false,
beta: 50.0,
edge_zone_distance: 50.0,
turn_scale: 30.0,
anchors: Vec::new(),
detection_cutoff_distance: 2.5,
}
}
}
#[derive(Debug, Clone)]
pub struct PathConfigBuilder {
distance_scale: f64,
heading_scale: f64,
cutoff_distance: f64,
heading_cutoff: f64,
probability_threshold: f64,
resampling_distance: Option<f64>,
max_candidates: usize,
path_only: bool,
debug_mode: bool,
beta: f64,
edge_zone_distance: f64,
turn_scale: f64,
anchors: Vec<ResolvedAnchor>,
detection_cutoff_distance: f64,
}
impl Default for PathConfigBuilder {
fn default() -> Self {
let defaults = PathConfig::default();
Self {
distance_scale: defaults.distance_scale,
heading_scale: defaults.heading_scale,
cutoff_distance: defaults.cutoff_distance,
heading_cutoff: defaults.heading_cutoff,
probability_threshold: defaults.probability_threshold,
resampling_distance: defaults.resampling_distance,
max_candidates: defaults.max_candidates,
path_only: defaults.path_only,
debug_mode: defaults.debug_mode,
beta: defaults.beta,
edge_zone_distance: defaults.edge_zone_distance,
turn_scale: defaults.turn_scale,
anchors: defaults.anchors,
detection_cutoff_distance: defaults.detection_cutoff_distance,
}
}
}
impl PathConfigBuilder {
pub fn distance_scale(mut self, value: f64) -> Self {
self.distance_scale = value;
self
}
pub fn heading_scale(mut self, value: f64) -> Self {
self.heading_scale = value;
self
}
pub fn cutoff_distance(mut self, value: f64) -> Self {
self.cutoff_distance = value;
self
}
pub fn heading_cutoff(mut self, value: f64) -> Self {
self.heading_cutoff = value;
self
}
pub fn probability_threshold(mut self, value: f64) -> Self {
self.probability_threshold = value;
self
}
pub fn resampling_distance(mut self, value: Option<f64>) -> Self {
self.resampling_distance = value;
self
}
pub fn max_candidates(mut self, value: usize) -> Self {
self.max_candidates = value;
self
}
pub fn path_only(mut self, value: bool) -> Self {
self.path_only = value;
self
}
pub fn debug_mode(mut self, value: bool) -> Self {
self.debug_mode = value;
self
}
pub fn beta(mut self, value: f64) -> Self {
self.beta = value;
self
}
pub fn edge_zone_distance(mut self, value: f64) -> Self {
self.edge_zone_distance = value;
self
}
pub fn turn_scale(mut self, value: f64) -> Self {
self.turn_scale = value;
self
}
pub fn anchors(mut self, value: Vec<ResolvedAnchor>) -> Self {
self.anchors = value;
self
}
pub fn detection_cutoff_distance(mut self, value: f64) -> Self {
self.detection_cutoff_distance = value;
self
}
pub fn build(self) -> Result<PathConfig, ProjectionError> {
let mut config = PathConfig::new(
self.distance_scale,
self.heading_scale,
self.cutoff_distance,
self.heading_cutoff,
self.probability_threshold,
self.resampling_distance,
self.max_candidates,
self.path_only,
self.debug_mode,
self.beta,
self.edge_zone_distance,
self.turn_scale,
)?;
config.anchors = self.anchors;
config.detection_cutoff_distance = self.detection_cutoff_distance;
config.validate()?;
Ok(config)
}
}
pub mod candidate;
pub mod debug;
pub mod graph;
pub mod probability;
pub mod spacing;
pub mod viterbi;
#[cfg(test)]
mod tests;
pub use candidate::*;
pub use debug::{
export_all_debug_info, export_gap_fills, export_hmm_candidate_netelements,
export_hmm_emission_probabilities, export_hmm_selected_path, export_hmm_viterbi_trace,
export_path_sanity_decisions,
};
pub use graph::{
build_topology_graph, cached_shortest_path_distance, shortest_path_distance,
shortest_path_route, validate_netrelation_references, NetelementSide, ShortestPathCache,
};
pub use probability::*;
pub use spacing::{calculate_mean_spacing, select_resampled_subset};
pub use viterbi::{
build_path_from_viterbi, fill_path_gaps, validate_path_navigability, viterbi_decode, GapFill,
SanityDecision, ViterbiResult, ViterbiSubsequence,
};
pub use PathCalculationMode::{FallbackIndependent, TopologyBased};
pub fn calculate_train_path(
gnss_positions: &[crate::models::GnssPosition],
netelements: &[crate::models::Netelement],
netrelations: &[crate::models::NetRelation],
config: &PathConfig,
) -> Result<PathResult, crate::errors::ProjectionError> {
use crate::path::candidate::find_candidate_netelements;
use crate::path::probability::{
calculate_combined_probability, calculate_distance_probability,
calculate_heading_probability,
};
use std::collections::HashMap;
if netelements.is_empty() {
return Err(crate::errors::ProjectionError::EmptyNetwork);
}
if gnss_positions.is_empty() {
return Err(crate::errors::ProjectionError::PathCalculationFailed {
reason: "No GNSS positions provided".to_string(),
});
}
let mut debug_info = if config.debug_mode {
Some(DebugInfo::new())
} else {
None
};
let (working_positions, resampling_applied, gnss_index_map) = if let Some(resample_dist) =
config.resampling_distance
{
let indices = crate::path::spacing::select_resampled_subset(gnss_positions, resample_dist);
let subset: Vec<_> = indices.iter().map(|&i| &gnss_positions[i]).collect();
let map = build_original_to_working_index_map(&indices, gnss_positions.len());
(subset, indices.len() < gnss_positions.len(), Some(map))
} else {
(gnss_positions.iter().collect(), false, None)
};
if config.path_only {
}
let mut position_candidates: Vec<Vec<crate::path::candidate::CandidateNetElement>> = Vec::new();
for gnss in &working_positions {
let candidates = find_candidate_netelements(
gnss,
netelements,
config.cutoff_distance,
config.max_candidates,
)?;
position_candidates.push(candidates);
}
let netelement_index: HashMap<String, usize> = netelements
.iter()
.enumerate()
.map(|(idx, ne)| (ne.id.clone(), idx))
.collect();
let estimated_headings =
crate::path::candidate::estimate_headings_from_neighbors(&working_positions);
let mut position_probabilities: Vec<HashMap<usize, f64>> = Vec::new();
for (pos_idx, candidates) in position_candidates.iter().enumerate() {
let mut probs = HashMap::new();
let gnss = working_positions[pos_idx];
let mut debug_candidates: Vec<CandidateInfo> = Vec::new();
for candidate in candidates {
let netelement_idx =
netelement_index
.get(&candidate.netelement_id)
.ok_or_else(|| crate::errors::ProjectionError::PathCalculationFailed {
reason: format!(
"Netelement {} not found in index",
candidate.netelement_id
),
})?;
let dist_prob =
calculate_distance_probability(candidate.distance_meters, config.distance_scale);
let effective_heading = gnss.heading.or(estimated_headings[pos_idx]);
let heading_diff_value = if let Some(gnss_heading) = effective_heading {
use crate::path::candidate::{calculate_heading_at_point, heading_difference};
let netelement = &netelements[*netelement_idx];
let netelement_heading =
calculate_heading_at_point(&candidate.projected_point, &netelement.geometry)?;
Some(heading_difference(gnss_heading, netelement_heading))
} else {
None
};
let heading_prob = if let Some(heading_diff) = heading_diff_value {
calculate_heading_probability(
heading_diff,
config.heading_scale,
config.heading_cutoff,
)
} else {
1.0 };
let combined = calculate_combined_probability(dist_prob, heading_prob);
probs.insert(*netelement_idx, combined);
if config.debug_mode {
debug_candidates.push(CandidateInfo {
netelement_id: candidate.netelement_id.clone(),
distance: candidate.distance_meters,
heading_difference: heading_diff_value,
distance_probability: dist_prob,
heading_probability: if heading_diff_value.is_some() {
Some(heading_prob)
} else {
None
},
combined_probability: combined,
status: if combined >= config.probability_threshold {
"accepted".to_string()
} else {
"below_threshold".to_string()
},
projected_lat: candidate.projected_point.y(),
projected_lon: candidate.projected_point.x(),
});
}
}
if let Some(ref mut debug) = debug_info {
let selected = probs
.iter()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(&idx, _)| netelements[idx].id.clone());
debug.add_position_candidates(PositionCandidates {
position_index: pos_idx,
timestamp: gnss.timestamp.to_rfc3339(),
coordinates: (gnss.latitude, gnss.longitude),
candidates: debug_candidates,
selected_netelement: selected,
});
}
position_probabilities.push(probs);
}
let mut emission_probs: Vec<Vec<f64>> = position_candidates
.iter()
.enumerate()
.map(|(t, cands)| {
cands
.iter()
.map(|c| {
netelement_index
.get(&c.netelement_id)
.and_then(|idx| position_probabilities[t].get(idx))
.copied()
.unwrap_or(0.0)
})
.collect()
})
.collect();
if !config.anchors.is_empty() {
crate::detections::anchor::apply_anchors(
&config.anchors,
&mut position_candidates,
&mut emission_probs,
netelements,
&netelement_index,
gnss_index_map.as_deref(),
)
.map_err(|e| ProjectionError::PathCalculationFailed {
reason: format!("anchor injection failed: {}", e),
})?;
}
let (topo_graph, node_map) =
crate::path::graph::build_topology_graph(netelements, netrelations)?;
let mut sp_cache = crate::path::graph::ShortestPathCache::new();
let viterbi_result = crate::path::viterbi::viterbi_decode(
&position_candidates,
&emission_probs,
netelements,
&netelement_index,
&topo_graph,
&node_map,
&mut sp_cache,
config,
)?;
let path_segments = crate::path::viterbi::build_path_from_viterbi(
&viterbi_result,
&position_candidates,
netelements,
&netelement_index,
&topo_graph,
&node_map,
&mut sp_cache,
)?;
let (path_segments, nav_warnings, sanity_decisions) =
crate::path::viterbi::validate_path_navigability(
path_segments,
netelements,
&netelement_index,
&topo_graph,
&node_map,
&mut sp_cache,
);
if let Some(ref mut debug) = debug_info {
debug.sanity_decisions = sanity_decisions;
}
let (path_segments, gap_warnings, gap_fills) = crate::path::viterbi::fill_path_gaps(
path_segments,
&netelement_index,
&topo_graph,
&node_map,
&mut sp_cache,
);
if let Some(ref mut debug) = debug_info {
debug.gap_fills = gap_fills;
}
let path_probability = if viterbi_result.subsequences.is_empty() {
0.0
} else {
let total_log: f64 = viterbi_result
.subsequences
.iter()
.map(|s| s.log_probability)
.sum();
let total_states: usize = viterbi_result
.subsequences
.iter()
.map(|s| s.states.len())
.sum();
if total_states > 0 {
(total_log / total_states as f64).exp().min(1.0)
} else {
0.0
}
};
let final_path = if path_segments.is_empty() {
None
} else {
Some((path_segments, path_probability))
};
if let Some(ref mut debug) = debug_info {
let mut viterbi_state_ne_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
for subseq in &viterbi_result.subsequences {
for &(t, c_idx) in &subseq.states {
viterbi_state_ne_ids.insert(position_candidates[t][c_idx].netelement_id.clone());
}
}
let mut final_path_ne_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
if let Some((ref segments, _)) = final_path {
for seg in segments {
final_path_ne_ids.insert(seg.netelement_id.clone());
}
}
let mut ne_emission_sums: HashMap<String, (f64, usize)> = HashMap::new();
for (t, cands) in position_candidates.iter().enumerate() {
for (c_idx, cand) in cands.iter().enumerate() {
let emission = emission_probs[t][c_idx];
let entry = ne_emission_sums
.entry(cand.netelement_id.clone())
.or_insert((0.0, 0));
entry.0 += emission;
entry.1 += 1;
}
}
for (ne_id, (sum, count)) in &ne_emission_sums {
let geometry_coords: Vec<Vec<f64>> = netelements
.iter()
.find(|ne| ne.id == *ne_id)
.map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
.unwrap_or_default();
debug
.netelement_probabilities
.push(NetelementProbabilityInfo {
netelement_id: ne_id.clone(),
avg_emission_probability: if *count > 0 { sum / *count as f64 } else { 0.0 },
position_count: *count,
geometry_coords,
in_viterbi_path: final_path_ne_ids.contains(ne_id),
is_bridge: final_path_ne_ids.contains(ne_id)
&& !viterbi_state_ne_ids.contains(ne_id),
});
}
for ne_id in &final_path_ne_ids {
if !ne_emission_sums.contains_key(ne_id) {
let geometry_coords: Vec<Vec<f64>> = netelements
.iter()
.find(|ne| ne.id == *ne_id)
.map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
.unwrap_or_default();
debug
.netelement_probabilities
.push(NetelementProbabilityInfo {
netelement_id: ne_id.clone(),
avg_emission_probability: 0.0,
position_count: 0,
geometry_coords,
in_viterbi_path: true,
is_bridge: true,
});
}
}
for (sub_idx, subseq) in viterbi_result.subsequences.iter().enumerate() {
let mut segment_ids: Vec<String> = Vec::new();
let mut segment_probs: Vec<f64> = Vec::new();
for &(t, c_idx) in &subseq.states {
let ne_id = &position_candidates[t][c_idx].netelement_id;
let emission = emission_probs[t][c_idx];
if segment_ids.last() != Some(ne_id) {
segment_ids.push(ne_id.clone());
segment_probs.push(emission);
}
}
debug.add_candidate_path(CandidatePath {
id: format!("viterbi_{}", sub_idx),
direction: "viterbi".to_string(),
segment_ids,
segment_probabilities: segment_probs,
probability: subseq.log_probability.exp().min(1.0),
selected: true,
});
}
for subseq in &viterbi_result.subsequences {
for (state_idx, &(t, c_idx)) in subseq.states.iter().enumerate() {
let chosen_ne = &position_candidates[t][c_idx].netelement_id;
let options: Vec<String> = position_candidates[t]
.iter()
.map(|c| c.netelement_id.clone())
.collect();
let option_probs: Vec<f64> = emission_probs[t].clone();
let decision_type = if state_idx == 0 {
"viterbi_init".to_string()
} else {
"viterbi_transition".to_string()
};
debug.add_decision(PathDecision {
step: t,
decision_type,
current_segment: chosen_ne.clone(),
options,
option_probabilities: option_probs,
chosen_option: chosen_ne.clone(),
reason: format!(
"Viterbi best state (log_prob: {:.4})",
subseq.log_probability
),
});
}
}
let chosen_pairs: std::collections::HashSet<(usize, usize, usize, usize)> = viterbi_result
.subsequences
.iter()
.flat_map(|subseq| subseq.states.windows(2))
.map(|w| (w[0].0, w[0].1, w[1].0, w[1].1))
.collect();
for &(from_t, from_idx, to_t, to_idx, prob) in &viterbi_result.transition_records {
debug
.transition_probabilities
.push(TransitionProbabilityEntry {
from_step: from_t,
to_step: to_t,
from_netelement_id: position_candidates[from_t][from_idx].netelement_id.clone(),
to_netelement_id: position_candidates[to_t][to_idx].netelement_id.clone(),
transition_probability: prob,
is_viterbi_chosen: chosen_pairs.contains(&(from_t, from_idx, to_t, to_idx)),
});
}
}
let train_path = if let Some((segments, prob)) = final_path {
use chrono::Utc;
let timestamp = gnss_positions
.first()
.map(|p| p.timestamp.with_timezone(&Utc));
Some(crate::models::TrainPath::new(
segments, prob, timestamp, None, )?)
} else {
None
};
let mut warnings = Vec::new();
warnings.extend(nav_warnings);
warnings.extend(gap_warnings);
if config.path_only {
warnings.push("Path-only mode enabled: skipping projection phase".to_string());
}
if resampling_applied {
warnings.push(format!(
"Resampling applied: used {} of {} positions for path calculation",
working_positions.len(),
gnss_positions.len()
));
}
if train_path.is_none() {
warnings.push("No continuous path found using topology-based calculation".to_string());
warnings.push("Viterbi decoding produced no valid path".to_string());
tracing::warn!(
gnss_count = gnss_positions.len(),
netelement_count = netelements.len(),
viterbi_subsequences = viterbi_result.subsequences.len(),
"Path calculation failed, falling back to independent projection"
);
let fallback_positions = if config.path_only {
warnings.push("Path-only mode: skipping fallback projection".to_string());
Vec::new()
} else {
warnings.push("Falling back to independent nearest-segment projection".to_string());
use crate::projection::{find_nearest_netelement, NetworkIndex};
let network_index = NetworkIndex::new(netelements.to_vec())?;
let mut positions = Vec::new();
for gnss in gnss_positions {
use geo::Point;
let gnss_point = Point::new(gnss.longitude, gnss.latitude);
if let Ok(netelement_idx) = find_nearest_netelement(&gnss_point, &network_index) {
let nearest = &network_index.netelements()[netelement_idx];
use crate::projection::project_point_onto_linestring;
let projected_point =
project_point_onto_linestring(&gnss_point, &nearest.geometry)?;
use crate::projection::calculate_measure_along_linestring;
let measure =
calculate_measure_along_linestring(&nearest.geometry, &projected_point)?;
use geo::HaversineDistance;
let distance = gnss_point.haversine_distance(&projected_point);
let projected = crate::models::ProjectedPosition::new(
gnss.clone(),
projected_point,
nearest.id.clone(),
measure,
distance,
gnss.crs.clone(),
);
positions.push(projected);
}
}
positions
};
let mut result = PathResult::new(
None, PathCalculationMode::FallbackIndependent,
fallback_positions,
warnings,
);
result.debug_info = debug_info;
return Ok(result);
}
let mut result = PathResult::new(
train_path,
PathCalculationMode::TopologyBased,
vec![], warnings,
);
result.debug_info = debug_info;
Ok(result)
}
fn build_original_to_working_index_map(
selected_original_indices: &[usize],
original_len: usize,
) -> Vec<usize> {
if selected_original_indices.is_empty() {
return vec![0; original_len];
}
let mut out = vec![0; original_len];
let mut left = 0usize;
for (original_idx, slot) in out.iter_mut().enumerate() {
while left + 1 < selected_original_indices.len()
&& selected_original_indices[left + 1] <= original_idx
{
left += 1;
}
let mut best = left;
if left + 1 < selected_original_indices.len() {
let dist_left = original_idx.abs_diff(selected_original_indices[left]);
let dist_right = selected_original_indices[left + 1].abs_diff(original_idx);
if dist_right < dist_left {
best = left + 1;
}
}
*slot = best;
}
out
}
pub fn project_onto_path(
gnss_positions: &[crate::models::GnssPosition],
path: &crate::models::TrainPath,
netelements: &[crate::models::Netelement],
_config: &PathConfig,
) -> Result<Vec<crate::models::ProjectedPosition>, crate::errors::ProjectionError> {
use crate::projection::geom::{
calculate_measure_along_linestring, project_point_onto_linestring,
};
use geo::{HaversineLength, Point};
use std::collections::HashMap;
if gnss_positions.is_empty() {
return Err(crate::errors::ProjectionError::PathCalculationFailed {
reason: "No GNSS positions provided".to_string(),
});
}
if path.segments.is_empty() {
return Err(crate::errors::ProjectionError::PathCalculationFailed {
reason: "Path has no segments".to_string(),
});
}
let netelement_map: HashMap<_, _> = netelements.iter().map(|ne| (ne.id.as_str(), ne)).collect();
for segment in &path.segments {
if !netelement_map.contains_key(segment.netelement_id.as_str()) {
return Err(crate::errors::ProjectionError::PathCalculationFailed {
reason: format!(
"Netelement {} in path not found in network",
segment.netelement_id
),
});
}
}
let mut projected_positions = Vec::with_capacity(gnss_positions.len());
for gnss in gnss_positions {
let mut best_distance = f64::MAX;
let mut best_segment_idx = 0;
let gnss_point = Point::new(gnss.longitude, gnss.latitude);
for (idx, segment) in path.segments.iter().enumerate() {
let netelement = netelement_map[segment.netelement_id.as_str()];
if let Ok(projected_point) =
project_point_onto_linestring(&gnss_point, &netelement.geometry)
{
use geo::HaversineDistance;
let distance = gnss_point.haversine_distance(&projected_point);
if distance < best_distance {
best_distance = distance;
best_segment_idx = idx;
}
}
}
let best_segment = &path.segments[best_segment_idx];
let best_netelement = netelement_map[best_segment.netelement_id.as_str()];
let projected_point =
project_point_onto_linestring(&gnss_point, &best_netelement.geometry)?;
let distance_along =
calculate_measure_along_linestring(&best_netelement.geometry, &projected_point)?;
let total_length = best_netelement.geometry.haversine_length();
let intrinsic = if total_length > 0.0 {
distance_along / total_length
} else {
0.0
};
if !(0.0..=1.0).contains(&intrinsic) {
return Err(crate::errors::ProjectionError::PathCalculationFailed {
reason: format!(
"Intrinsic coordinate {} outside valid range [0, 1]",
intrinsic
),
});
}
projected_positions.push(crate::models::ProjectedPosition::with_intrinsic(
gnss.clone(),
projected_point,
best_netelement.id.clone(),
distance_along,
best_distance,
gnss.crs.clone(),
intrinsic,
));
}
Ok(projected_positions)
}