1use crate::errors::ProjectionError;
41use crate::models::{DetectionRecord, ResolvedAnchor, TrainPath};
42use serde::{Deserialize, Serialize};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum PathCalculationMode {
50 TopologyBased,
52
53 FallbackIndependent,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PathResult {
63 pub path: Option<TrainPath>,
65
66 pub mode: PathCalculationMode,
68
69 pub projected_positions: Vec<crate::models::ProjectedPosition>,
71
72 pub warnings: Vec<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub debug_info: Option<DebugInfo>,
78
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub detection_provenance: Vec<DetectionRecord>,
84}
85
86impl PathResult {
87 pub fn new(
89 path: Option<TrainPath>,
90 mode: PathCalculationMode,
91 projected_positions: Vec<crate::models::ProjectedPosition>,
92 warnings: Vec<String>,
93 ) -> Self {
94 Self {
95 path,
96 mode,
97 projected_positions,
98 warnings,
99 debug_info: None,
100 detection_provenance: Vec::new(),
101 }
102 }
103
104 pub fn with_debug_info(
106 path: Option<TrainPath>,
107 mode: PathCalculationMode,
108 projected_positions: Vec<crate::models::ProjectedPosition>,
109 warnings: Vec<String>,
110 debug_info: DebugInfo,
111 ) -> Self {
112 Self {
113 path,
114 mode,
115 projected_positions,
116 warnings,
117 debug_info: Some(debug_info),
118 detection_provenance: Vec::new(),
119 }
120 }
121
122 pub fn has_debug_info(&self) -> bool {
124 self.debug_info.is_some()
125 }
126
127 pub fn is_topology_based(&self) -> bool {
129 self.mode == PathCalculationMode::TopologyBased
130 }
131
132 pub fn is_fallback(&self) -> bool {
134 self.mode == PathCalculationMode::FallbackIndependent
135 }
136
137 pub fn has_path(&self) -> bool {
139 self.path.is_some()
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub struct DebugInfo {
149 pub candidate_paths: Vec<CandidatePath>,
151
152 pub position_candidates: Vec<PositionCandidates>,
154
155 pub decision_tree: Vec<PathDecision>,
157
158 pub netelement_probabilities: Vec<NetelementProbabilityInfo>,
160
161 pub transition_probabilities: Vec<TransitionProbabilityEntry>,
163
164 pub sanity_decisions: Vec<viterbi::SanityDecision>,
166
167 pub gap_fills: Vec<viterbi::GapFill>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct NetelementProbabilityInfo {
174 pub netelement_id: String,
176
177 pub avg_emission_probability: f64,
179
180 pub position_count: usize,
182
183 pub geometry_coords: Vec<Vec<f64>>,
185
186 pub in_viterbi_path: bool,
188
189 pub is_bridge: bool,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct CandidatePath {
196 pub id: String,
198
199 pub direction: String,
201
202 pub segment_ids: Vec<String>,
204
205 pub segment_probabilities: Vec<f64>,
207
208 pub probability: f64,
210
211 pub selected: bool,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct PositionCandidates {
218 pub position_index: usize,
220
221 pub timestamp: String,
223
224 pub coordinates: (f64, f64),
226
227 pub candidates: Vec<CandidateInfo>,
229
230 pub selected_netelement: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CandidateInfo {
237 pub netelement_id: String,
239
240 pub distance: f64,
242
243 pub heading_difference: Option<f64>,
245
246 pub distance_probability: f64,
248
249 pub heading_probability: Option<f64>,
251
252 pub combined_probability: f64,
254
255 pub status: String,
257
258 pub projected_lat: f64,
260
261 pub projected_lon: f64,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct TransitionProbabilityEntry {
268 pub from_step: usize,
270 pub to_step: usize,
272 pub from_netelement_id: String,
274 pub to_netelement_id: String,
276 pub transition_probability: f64,
278 pub is_viterbi_chosen: bool,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct PathDecision {
285 pub step: usize,
287
288 pub decision_type: String,
290
291 pub current_segment: String,
293
294 pub options: Vec<String>,
296
297 pub option_probabilities: Vec<f64>,
299
300 pub chosen_option: String,
302
303 pub reason: String,
305}
306
307impl DebugInfo {
308 pub fn new() -> Self {
310 Self::default()
311 }
312
313 pub fn add_candidate_path(&mut self, path: CandidatePath) {
315 self.candidate_paths.push(path);
316 }
317
318 pub fn add_position_candidates(&mut self, candidates: PositionCandidates) {
320 self.position_candidates.push(candidates);
321 }
322
323 pub fn add_decision(&mut self, decision: PathDecision) {
325 self.decision_tree.push(decision);
326 }
327
328 pub fn to_json(&self) -> Result<String, serde_json::Error> {
330 serde_json::to_string_pretty(self)
331 }
332
333 pub fn is_empty(&self) -> bool {
335 self.candidate_paths.is_empty()
336 && self.position_candidates.is_empty()
337 && self.decision_tree.is_empty()
338 && self.netelement_probabilities.is_empty()
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct PathConfig {
366 pub distance_scale: f64,
369
370 pub heading_scale: f64,
373
374 pub cutoff_distance: f64,
376
377 pub heading_cutoff: f64,
380
381 pub probability_threshold: f64,
383
384 pub resampling_distance: Option<f64>,
386
387 pub max_candidates: usize,
389
390 pub path_only: bool,
393
394 pub debug_mode: bool,
397
398 pub beta: f64,
402
403 pub edge_zone_distance: f64,
408
409 pub turn_scale: f64,
414
415 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub anchors: Vec<ResolvedAnchor>,
420
421 #[serde(default = "default_detection_cutoff_distance")]
426 pub detection_cutoff_distance: f64,
427}
428
429fn default_detection_cutoff_distance() -> f64 {
430 2.5
431}
432
433impl PathConfig {
434 #[allow(clippy::too_many_arguments)]
436 pub fn new(
437 distance_scale: f64,
438 heading_scale: f64,
439 cutoff_distance: f64,
440 heading_cutoff: f64,
441 probability_threshold: f64,
442 resampling_distance: Option<f64>,
443 max_candidates: usize,
444 path_only: bool,
445 debug_mode: bool,
446 beta: f64,
447 edge_zone_distance: f64,
448 turn_scale: f64,
449 ) -> Result<Self, ProjectionError> {
450 let config = Self {
451 distance_scale,
452 heading_scale,
453 cutoff_distance,
454 heading_cutoff,
455 probability_threshold,
456 resampling_distance,
457 max_candidates,
458 path_only,
459 debug_mode,
460 beta,
461 edge_zone_distance,
462 turn_scale,
463 anchors: Vec::new(),
464 detection_cutoff_distance: default_detection_cutoff_distance(),
465 };
466
467 config.validate()?;
468 Ok(config)
469 }
470
471 fn validate(&self) -> Result<(), ProjectionError> {
473 if self.distance_scale <= 0.0 {
474 return Err(ProjectionError::InvalidGeometry(
475 "distance_scale must be positive".to_string(),
476 ));
477 }
478
479 if self.heading_scale <= 0.0 {
480 return Err(ProjectionError::InvalidGeometry(
481 "heading_scale must be positive".to_string(),
482 ));
483 }
484
485 if self.cutoff_distance <= 0.0 {
486 return Err(ProjectionError::InvalidGeometry(
487 "cutoff_distance must be positive".to_string(),
488 ));
489 }
490
491 if !(0.0..=90.0).contains(&self.heading_cutoff) {
492 return Err(ProjectionError::InvalidGeometry(
493 "heading_cutoff must be in [0, 90]".to_string(),
494 ));
495 }
496
497 if !(0.0..=1.0).contains(&self.probability_threshold) {
498 return Err(ProjectionError::InvalidGeometry(
499 "probability_threshold must be in [0, 1]".to_string(),
500 ));
501 }
502
503 if let Some(resampling) = self.resampling_distance {
504 if resampling <= 0.0 {
505 return Err(ProjectionError::InvalidGeometry(
506 "resampling_distance must be positive".to_string(),
507 ));
508 }
509 }
510
511 if self.max_candidates == 0 {
512 return Err(ProjectionError::InvalidGeometry(
513 "max_candidates must be at least 1".to_string(),
514 ));
515 }
516
517 if self.beta <= 0.0 {
518 return Err(ProjectionError::InvalidGeometry(
519 "beta must be positive".to_string(),
520 ));
521 }
522
523 if self.edge_zone_distance <= 0.0 {
524 return Err(ProjectionError::InvalidGeometry(
525 "edge_zone_distance must be positive".to_string(),
526 ));
527 }
528
529 if self.turn_scale <= 0.0 {
530 return Err(ProjectionError::InvalidGeometry(
531 "turn_scale must be positive".to_string(),
532 ));
533 }
534
535 if self.detection_cutoff_distance <= 0.0 {
536 return Err(ProjectionError::InvalidGeometry(
537 "detection_cutoff_distance must be positive".to_string(),
538 ));
539 }
540
541 Ok(())
542 }
543
544 pub fn builder() -> PathConfigBuilder {
546 PathConfigBuilder::default()
547 }
548}
549
550impl Default for PathConfig {
551 fn default() -> Self {
567 Self {
568 distance_scale: 10.0,
569 heading_scale: 2.0,
570 cutoff_distance: 500.0,
571 heading_cutoff: 10.0,
572 probability_threshold: 0.02,
573 resampling_distance: None,
574 max_candidates: 3,
575 path_only: false,
576 debug_mode: false,
577 beta: 50.0,
578 edge_zone_distance: 50.0,
579 turn_scale: 30.0,
580 anchors: Vec::new(),
581 detection_cutoff_distance: 2.5,
582 }
583 }
584}
585
586#[derive(Debug, Clone)]
607pub struct PathConfigBuilder {
608 distance_scale: f64,
609 heading_scale: f64,
610 cutoff_distance: f64,
611 heading_cutoff: f64,
612 probability_threshold: f64,
613 resampling_distance: Option<f64>,
614 max_candidates: usize,
615 path_only: bool,
616 debug_mode: bool,
617 beta: f64,
618 edge_zone_distance: f64,
619 turn_scale: f64,
620 anchors: Vec<ResolvedAnchor>,
621 detection_cutoff_distance: f64,
622}
623
624impl Default for PathConfigBuilder {
625 fn default() -> Self {
626 let defaults = PathConfig::default();
627 Self {
628 distance_scale: defaults.distance_scale,
629 heading_scale: defaults.heading_scale,
630 cutoff_distance: defaults.cutoff_distance,
631 heading_cutoff: defaults.heading_cutoff,
632 probability_threshold: defaults.probability_threshold,
633 resampling_distance: defaults.resampling_distance,
634 max_candidates: defaults.max_candidates,
635 path_only: defaults.path_only,
636 debug_mode: defaults.debug_mode,
637 beta: defaults.beta,
638 edge_zone_distance: defaults.edge_zone_distance,
639 turn_scale: defaults.turn_scale,
640 anchors: defaults.anchors,
641 detection_cutoff_distance: defaults.detection_cutoff_distance,
642 }
643 }
644}
645
646impl PathConfigBuilder {
647 pub fn distance_scale(mut self, value: f64) -> Self {
649 self.distance_scale = value;
650 self
651 }
652
653 pub fn heading_scale(mut self, value: f64) -> Self {
655 self.heading_scale = value;
656 self
657 }
658
659 pub fn cutoff_distance(mut self, value: f64) -> Self {
661 self.cutoff_distance = value;
662 self
663 }
664
665 pub fn heading_cutoff(mut self, value: f64) -> Self {
667 self.heading_cutoff = value;
668 self
669 }
670
671 pub fn probability_threshold(mut self, value: f64) -> Self {
673 self.probability_threshold = value;
674 self
675 }
676
677 pub fn resampling_distance(mut self, value: Option<f64>) -> Self {
679 self.resampling_distance = value;
680 self
681 }
682
683 pub fn max_candidates(mut self, value: usize) -> Self {
685 self.max_candidates = value;
686 self
687 }
688
689 pub fn path_only(mut self, value: bool) -> Self {
691 self.path_only = value;
692 self
693 }
694
695 pub fn debug_mode(mut self, value: bool) -> Self {
697 self.debug_mode = value;
698 self
699 }
700
701 pub fn beta(mut self, value: f64) -> Self {
703 self.beta = value;
704 self
705 }
706
707 pub fn edge_zone_distance(mut self, value: f64) -> Self {
709 self.edge_zone_distance = value;
710 self
711 }
712
713 pub fn turn_scale(mut self, value: f64) -> Self {
715 self.turn_scale = value;
716 self
717 }
718
719 pub fn anchors(mut self, value: Vec<ResolvedAnchor>) -> Self {
721 self.anchors = value;
722 self
723 }
724
725 pub fn detection_cutoff_distance(mut self, value: f64) -> Self {
727 self.detection_cutoff_distance = value;
728 self
729 }
730
731 pub fn build(self) -> Result<PathConfig, ProjectionError> {
733 let mut config = PathConfig::new(
734 self.distance_scale,
735 self.heading_scale,
736 self.cutoff_distance,
737 self.heading_cutoff,
738 self.probability_threshold,
739 self.resampling_distance,
740 self.max_candidates,
741 self.path_only,
742 self.debug_mode,
743 self.beta,
744 self.edge_zone_distance,
745 self.turn_scale,
746 )?;
747 config.anchors = self.anchors;
748 config.detection_cutoff_distance = self.detection_cutoff_distance;
749 config.validate()?;
750 Ok(config)
751 }
752}
753
754pub mod candidate;
755pub mod debug;
756pub mod graph;
757pub mod probability;
758pub mod spacing;
759pub mod viterbi;
760
761#[cfg(test)]
762mod tests;
763
764pub use candidate::*;
766pub use debug::{
767 export_all_debug_info, export_gap_fills, export_hmm_candidate_netelements,
768 export_hmm_emission_probabilities, export_hmm_selected_path, export_hmm_viterbi_trace,
769 export_path_sanity_decisions,
770};
771pub use graph::{
772 build_topology_graph, cached_shortest_path_distance, shortest_path_distance,
773 shortest_path_route, validate_netrelation_references, NetelementSide, ShortestPathCache,
774};
775pub use probability::*;
776pub use spacing::{calculate_mean_spacing, select_resampled_subset};
777pub use viterbi::{
778 build_path_from_viterbi, fill_path_gaps, validate_path_navigability, viterbi_decode, GapFill,
779 SanityDecision, ViterbiResult, ViterbiSubsequence,
780};
781
782pub use PathCalculationMode::{FallbackIndependent, TopologyBased};
784pub fn calculate_train_path(
857 gnss_positions: &[crate::models::GnssPosition],
858 netelements: &[crate::models::Netelement],
859 netrelations: &[crate::models::NetRelation],
860 config: &PathConfig,
861) -> Result<PathResult, crate::errors::ProjectionError> {
862 use crate::path::candidate::find_candidate_netelements;
863 use crate::path::probability::{
864 calculate_combined_probability, calculate_distance_probability,
865 calculate_heading_probability,
866 };
867 use std::collections::HashMap;
868
869 if netelements.is_empty() {
871 return Err(crate::errors::ProjectionError::EmptyNetwork);
872 }
873 if gnss_positions.is_empty() {
874 return Err(crate::errors::ProjectionError::PathCalculationFailed {
875 reason: "No GNSS positions provided".to_string(),
876 });
877 }
878
879 let mut debug_info = if config.debug_mode {
881 Some(DebugInfo::new())
882 } else {
883 None
884 };
885
886 let (working_positions, resampling_applied, gnss_index_map) = if let Some(resample_dist) =
888 config.resampling_distance
889 {
890 let indices = crate::path::spacing::select_resampled_subset(gnss_positions, resample_dist);
891 let subset: Vec<_> = indices.iter().map(|&i| &gnss_positions[i]).collect();
892 let map = build_original_to_working_index_map(&indices, gnss_positions.len());
893 (subset, indices.len() < gnss_positions.len(), Some(map))
894 } else {
895 (gnss_positions.iter().collect(), false, None)
897 };
898
899 if config.path_only {
901 }
905
906 let mut position_candidates: Vec<Vec<crate::path::candidate::CandidateNetElement>> = Vec::new();
909
910 for gnss in &working_positions {
911 let candidates = find_candidate_netelements(
912 gnss,
913 netelements,
914 config.cutoff_distance,
915 config.max_candidates,
916 )?;
917 position_candidates.push(candidates);
918 }
919
920 let netelement_index: HashMap<String, usize> = netelements
924 .iter()
925 .enumerate()
926 .map(|(idx, ne)| (ne.id.clone(), idx))
927 .collect();
928
929 let estimated_headings =
932 crate::path::candidate::estimate_headings_from_neighbors(&working_positions);
933
934 let mut position_probabilities: Vec<HashMap<usize, f64>> = Vec::new(); for (pos_idx, candidates) in position_candidates.iter().enumerate() {
937 let mut probs = HashMap::new();
938 let gnss = working_positions[pos_idx]; let mut debug_candidates: Vec<CandidateInfo> = Vec::new();
942
943 for candidate in candidates {
944 let netelement_idx =
945 netelement_index
946 .get(&candidate.netelement_id)
947 .ok_or_else(|| crate::errors::ProjectionError::PathCalculationFailed {
948 reason: format!(
949 "Netelement {} not found in index",
950 candidate.netelement_id
951 ),
952 })?;
953
954 let dist_prob =
956 calculate_distance_probability(candidate.distance_meters, config.distance_scale);
957
958 let effective_heading = gnss.heading.or(estimated_headings[pos_idx]);
961
962 let heading_diff_value = if let Some(gnss_heading) = effective_heading {
963 use crate::path::candidate::{calculate_heading_at_point, heading_difference};
964 let netelement = &netelements[*netelement_idx];
965 let netelement_heading =
966 calculate_heading_at_point(&candidate.projected_point, &netelement.geometry)?;
967 Some(heading_difference(gnss_heading, netelement_heading))
968 } else {
969 None
970 };
971
972 let heading_prob = if let Some(heading_diff) = heading_diff_value {
973 calculate_heading_probability(
974 heading_diff,
975 config.heading_scale,
976 config.heading_cutoff,
977 )
978 } else {
979 1.0 };
981
982 let combined = calculate_combined_probability(dist_prob, heading_prob);
984 probs.insert(*netelement_idx, combined);
985
986 if config.debug_mode {
988 debug_candidates.push(CandidateInfo {
989 netelement_id: candidate.netelement_id.clone(),
990 distance: candidate.distance_meters,
991 heading_difference: heading_diff_value,
992 distance_probability: dist_prob,
993 heading_probability: if heading_diff_value.is_some() {
994 Some(heading_prob)
995 } else {
996 None
997 },
998 combined_probability: combined,
999 status: if combined >= config.probability_threshold {
1000 "accepted".to_string()
1001 } else {
1002 "below_threshold".to_string()
1003 },
1004 projected_lat: candidate.projected_point.y(),
1005 projected_lon: candidate.projected_point.x(),
1006 });
1007 }
1008 }
1009
1010 if let Some(ref mut debug) = debug_info {
1012 let selected = probs
1013 .iter()
1014 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
1015 .map(|(&idx, _)| netelements[idx].id.clone());
1016
1017 debug.add_position_candidates(PositionCandidates {
1018 position_index: pos_idx,
1019 timestamp: gnss.timestamp.to_rfc3339(),
1020 coordinates: (gnss.latitude, gnss.longitude),
1021 candidates: debug_candidates,
1022 selected_netelement: selected,
1023 });
1024 }
1025
1026 position_probabilities.push(probs);
1027 }
1028
1029 let mut emission_probs: Vec<Vec<f64>> = position_candidates
1036 .iter()
1037 .enumerate()
1038 .map(|(t, cands)| {
1039 cands
1040 .iter()
1041 .map(|c| {
1042 netelement_index
1043 .get(&c.netelement_id)
1044 .and_then(|idx| position_probabilities[t].get(idx))
1045 .copied()
1046 .unwrap_or(0.0)
1047 })
1048 .collect()
1049 })
1050 .collect();
1051
1052 if !config.anchors.is_empty() {
1056 crate::detections::anchor::apply_anchors(
1057 &config.anchors,
1058 &mut position_candidates,
1059 &mut emission_probs,
1060 netelements,
1061 &netelement_index,
1062 gnss_index_map.as_deref(),
1063 )
1064 .map_err(|e| ProjectionError::PathCalculationFailed {
1065 reason: format!("anchor injection failed: {}", e),
1066 })?;
1067 }
1068
1069 let (topo_graph, node_map) =
1071 crate::path::graph::build_topology_graph(netelements, netrelations)?;
1072 let mut sp_cache = crate::path::graph::ShortestPathCache::new();
1073
1074 let viterbi_result = crate::path::viterbi::viterbi_decode(
1076 &position_candidates,
1077 &emission_probs,
1078 netelements,
1079 &netelement_index,
1080 &topo_graph,
1081 &node_map,
1082 &mut sp_cache,
1083 config,
1084 )?;
1085
1086 let path_segments = crate::path::viterbi::build_path_from_viterbi(
1088 &viterbi_result,
1089 &position_candidates,
1090 netelements,
1091 &netelement_index,
1092 &topo_graph,
1093 &node_map,
1094 &mut sp_cache,
1095 )?;
1096
1097 let (path_segments, nav_warnings, sanity_decisions) =
1100 crate::path::viterbi::validate_path_navigability(
1101 path_segments,
1102 netelements,
1103 &netelement_index,
1104 &topo_graph,
1105 &node_map,
1106 &mut sp_cache,
1107 );
1108
1109 if let Some(ref mut debug) = debug_info {
1111 debug.sanity_decisions = sanity_decisions;
1112 }
1113
1114 let (path_segments, gap_warnings, gap_fills) = crate::path::viterbi::fill_path_gaps(
1117 path_segments,
1118 &netelement_index,
1119 &topo_graph,
1120 &node_map,
1121 &mut sp_cache,
1122 );
1123
1124 if let Some(ref mut debug) = debug_info {
1126 debug.gap_fills = gap_fills;
1127 }
1128
1129 let path_probability = if viterbi_result.subsequences.is_empty() {
1131 0.0
1132 } else {
1133 let total_log: f64 = viterbi_result
1135 .subsequences
1136 .iter()
1137 .map(|s| s.log_probability)
1138 .sum();
1139 let total_states: usize = viterbi_result
1140 .subsequences
1141 .iter()
1142 .map(|s| s.states.len())
1143 .sum();
1144 if total_states > 0 {
1145 (total_log / total_states as f64).exp().min(1.0)
1146 } else {
1147 0.0
1148 }
1149 };
1150
1151 let final_path = if path_segments.is_empty() {
1152 None
1153 } else {
1154 Some((path_segments, path_probability))
1155 };
1156
1157 if let Some(ref mut debug) = debug_info {
1159 let mut viterbi_state_ne_ids: std::collections::HashSet<String> =
1161 std::collections::HashSet::new();
1162 for subseq in &viterbi_result.subsequences {
1163 for &(t, c_idx) in &subseq.states {
1164 viterbi_state_ne_ids.insert(position_candidates[t][c_idx].netelement_id.clone());
1165 }
1166 }
1167
1168 let mut final_path_ne_ids: std::collections::HashSet<String> =
1171 std::collections::HashSet::new();
1172 if let Some((ref segments, _)) = final_path {
1173 for seg in segments {
1174 final_path_ne_ids.insert(seg.netelement_id.clone());
1175 }
1176 }
1177
1178 let mut ne_emission_sums: HashMap<String, (f64, usize)> = HashMap::new();
1180 for (t, cands) in position_candidates.iter().enumerate() {
1181 for (c_idx, cand) in cands.iter().enumerate() {
1182 let emission = emission_probs[t][c_idx];
1183 let entry = ne_emission_sums
1184 .entry(cand.netelement_id.clone())
1185 .or_insert((0.0, 0));
1186 entry.0 += emission;
1187 entry.1 += 1;
1188 }
1189 }
1190
1191 for (ne_id, (sum, count)) in &ne_emission_sums {
1192 let geometry_coords: Vec<Vec<f64>> = netelements
1193 .iter()
1194 .find(|ne| ne.id == *ne_id)
1195 .map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
1196 .unwrap_or_default();
1197
1198 debug
1199 .netelement_probabilities
1200 .push(NetelementProbabilityInfo {
1201 netelement_id: ne_id.clone(),
1202 avg_emission_probability: if *count > 0 { sum / *count as f64 } else { 0.0 },
1203 position_count: *count,
1204 geometry_coords,
1205 in_viterbi_path: final_path_ne_ids.contains(ne_id),
1206 is_bridge: final_path_ne_ids.contains(ne_id)
1207 && !viterbi_state_ne_ids.contains(ne_id),
1208 });
1209 }
1210
1211 for ne_id in &final_path_ne_ids {
1213 if !ne_emission_sums.contains_key(ne_id) {
1214 let geometry_coords: Vec<Vec<f64>> = netelements
1215 .iter()
1216 .find(|ne| ne.id == *ne_id)
1217 .map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
1218 .unwrap_or_default();
1219
1220 debug
1221 .netelement_probabilities
1222 .push(NetelementProbabilityInfo {
1223 netelement_id: ne_id.clone(),
1224 avg_emission_probability: 0.0,
1225 position_count: 0,
1226 geometry_coords,
1227 in_viterbi_path: true,
1228 is_bridge: true,
1229 });
1230 }
1231 }
1232
1233 for (sub_idx, subseq) in viterbi_result.subsequences.iter().enumerate() {
1235 let mut segment_ids: Vec<String> = Vec::new();
1237 let mut segment_probs: Vec<f64> = Vec::new();
1238 for &(t, c_idx) in &subseq.states {
1239 let ne_id = &position_candidates[t][c_idx].netelement_id;
1240 let emission = emission_probs[t][c_idx];
1241 if segment_ids.last() != Some(ne_id) {
1242 segment_ids.push(ne_id.clone());
1243 segment_probs.push(emission);
1244 }
1245 }
1246
1247 debug.add_candidate_path(CandidatePath {
1248 id: format!("viterbi_{}", sub_idx),
1249 direction: "viterbi".to_string(),
1250 segment_ids,
1251 segment_probabilities: segment_probs,
1252 probability: subseq.log_probability.exp().min(1.0),
1253 selected: true,
1254 });
1255 }
1256
1257 for subseq in &viterbi_result.subsequences {
1259 for (state_idx, &(t, c_idx)) in subseq.states.iter().enumerate() {
1260 let chosen_ne = &position_candidates[t][c_idx].netelement_id;
1261 let options: Vec<String> = position_candidates[t]
1262 .iter()
1263 .map(|c| c.netelement_id.clone())
1264 .collect();
1265 let option_probs: Vec<f64> = emission_probs[t].clone();
1266
1267 let decision_type = if state_idx == 0 {
1268 "viterbi_init".to_string()
1269 } else {
1270 "viterbi_transition".to_string()
1271 };
1272
1273 debug.add_decision(PathDecision {
1274 step: t,
1275 decision_type,
1276 current_segment: chosen_ne.clone(),
1277 options,
1278 option_probabilities: option_probs,
1279 chosen_option: chosen_ne.clone(),
1280 reason: format!(
1281 "Viterbi best state (log_prob: {:.4})",
1282 subseq.log_probability
1283 ),
1284 });
1285 }
1286 }
1287
1288 let chosen_pairs: std::collections::HashSet<(usize, usize, usize, usize)> = viterbi_result
1290 .subsequences
1291 .iter()
1292 .flat_map(|subseq| subseq.states.windows(2))
1293 .map(|w| (w[0].0, w[0].1, w[1].0, w[1].1))
1294 .collect();
1295
1296 for &(from_t, from_idx, to_t, to_idx, prob) in &viterbi_result.transition_records {
1297 debug
1298 .transition_probabilities
1299 .push(TransitionProbabilityEntry {
1300 from_step: from_t,
1301 to_step: to_t,
1302 from_netelement_id: position_candidates[from_t][from_idx].netelement_id.clone(),
1303 to_netelement_id: position_candidates[to_t][to_idx].netelement_id.clone(),
1304 transition_probability: prob,
1305 is_viterbi_chosen: chosen_pairs.contains(&(from_t, from_idx, to_t, to_idx)),
1306 });
1307 }
1308 }
1309
1310 let train_path = if let Some((segments, prob)) = final_path {
1312 use chrono::Utc;
1313 let timestamp = gnss_positions
1315 .first()
1316 .map(|p| p.timestamp.with_timezone(&Utc));
1317 Some(crate::models::TrainPath::new(
1318 segments, prob, timestamp, None, )?)
1320 } else {
1321 None
1322 };
1323
1324 let mut warnings = Vec::new();
1326 warnings.extend(nav_warnings);
1327 warnings.extend(gap_warnings);
1328 if config.path_only {
1329 warnings.push("Path-only mode enabled: skipping projection phase".to_string());
1330 }
1331 if resampling_applied {
1332 warnings.push(format!(
1333 "Resampling applied: used {} of {} positions for path calculation",
1334 working_positions.len(),
1335 gnss_positions.len()
1336 ));
1337 }
1338
1339 if train_path.is_none() {
1341 warnings.push("No continuous path found using topology-based calculation".to_string());
1342 warnings.push("Viterbi decoding produced no valid path".to_string());
1343
1344 tracing::warn!(
1346 gnss_count = gnss_positions.len(),
1347 netelement_count = netelements.len(),
1348 viterbi_subsequences = viterbi_result.subsequences.len(),
1349 "Path calculation failed, falling back to independent projection"
1350 );
1351
1352 let fallback_positions = if config.path_only {
1354 warnings.push("Path-only mode: skipping fallback projection".to_string());
1356 Vec::new()
1357 } else {
1358 warnings.push("Falling back to independent nearest-segment projection".to_string());
1359
1360 use crate::projection::{find_nearest_netelement, NetworkIndex};
1363 let network_index = NetworkIndex::new(netelements.to_vec())?;
1364
1365 let mut positions = Vec::new();
1366 for gnss in gnss_positions {
1367 use geo::Point;
1369 let gnss_point = Point::new(gnss.longitude, gnss.latitude);
1370
1371 if let Ok(netelement_idx) = find_nearest_netelement(&gnss_point, &network_index) {
1372 let nearest = &network_index.netelements()[netelement_idx];
1373
1374 use crate::projection::project_point_onto_linestring;
1375 let projected_point =
1376 project_point_onto_linestring(&gnss_point, &nearest.geometry)?;
1377
1378 use crate::projection::calculate_measure_along_linestring;
1379 let measure =
1380 calculate_measure_along_linestring(&nearest.geometry, &projected_point)?;
1381
1382 use geo::HaversineDistance;
1384 let distance = gnss_point.haversine_distance(&projected_point);
1385
1386 let projected = crate::models::ProjectedPosition::new(
1387 gnss.clone(),
1388 projected_point,
1389 nearest.id.clone(),
1390 measure,
1391 distance,
1392 gnss.crs.clone(),
1393 );
1394 positions.push(projected);
1395 }
1396 }
1397 positions
1398 };
1399
1400 let mut result = PathResult::new(
1402 None, PathCalculationMode::FallbackIndependent,
1404 fallback_positions,
1405 warnings,
1406 );
1407 result.debug_info = debug_info;
1408 return Ok(result);
1409 }
1410
1411 let mut result = PathResult::new(
1413 train_path,
1414 PathCalculationMode::TopologyBased,
1415 vec![], warnings,
1417 );
1418 result.debug_info = debug_info;
1419 Ok(result)
1420}
1421
1422fn build_original_to_working_index_map(
1423 selected_original_indices: &[usize],
1424 original_len: usize,
1425) -> Vec<usize> {
1426 if selected_original_indices.is_empty() {
1427 return vec![0; original_len];
1428 }
1429
1430 let mut out = vec![0; original_len];
1431 let mut left = 0usize;
1432 for (original_idx, slot) in out.iter_mut().enumerate() {
1433 while left + 1 < selected_original_indices.len()
1434 && selected_original_indices[left + 1] <= original_idx
1435 {
1436 left += 1;
1437 }
1438 let mut best = left;
1439 if left + 1 < selected_original_indices.len() {
1440 let dist_left = original_idx.abs_diff(selected_original_indices[left]);
1441 let dist_right = selected_original_indices[left + 1].abs_diff(original_idx);
1442 if dist_right < dist_left {
1443 best = left + 1;
1444 }
1445 }
1446 *slot = best;
1447 }
1448 out
1449}
1450
1451pub fn project_onto_path(
1500 gnss_positions: &[crate::models::GnssPosition],
1501 path: &crate::models::TrainPath,
1502 netelements: &[crate::models::Netelement],
1503 _config: &PathConfig,
1504) -> Result<Vec<crate::models::ProjectedPosition>, crate::errors::ProjectionError> {
1505 use crate::projection::geom::{
1506 calculate_measure_along_linestring, project_point_onto_linestring,
1507 };
1508 use geo::{HaversineLength, Point};
1509 use std::collections::HashMap;
1510
1511 if gnss_positions.is_empty() {
1513 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1514 reason: "No GNSS positions provided".to_string(),
1515 });
1516 }
1517
1518 if path.segments.is_empty() {
1519 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1520 reason: "Path has no segments".to_string(),
1521 });
1522 }
1523
1524 let netelement_map: HashMap<_, _> = netelements.iter().map(|ne| (ne.id.as_str(), ne)).collect();
1526
1527 for segment in &path.segments {
1529 if !netelement_map.contains_key(segment.netelement_id.as_str()) {
1530 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1531 reason: format!(
1532 "Netelement {} in path not found in network",
1533 segment.netelement_id
1534 ),
1535 });
1536 }
1537 }
1538
1539 let mut projected_positions = Vec::with_capacity(gnss_positions.len());
1540
1541 for gnss in gnss_positions {
1543 let mut best_distance = f64::MAX;
1545 let mut best_segment_idx = 0;
1546 let gnss_point = Point::new(gnss.longitude, gnss.latitude);
1547
1548 for (idx, segment) in path.segments.iter().enumerate() {
1549 let netelement = netelement_map[segment.netelement_id.as_str()];
1550
1551 if let Ok(projected_point) =
1553 project_point_onto_linestring(&gnss_point, &netelement.geometry)
1554 {
1555 use geo::HaversineDistance;
1556 let distance = gnss_point.haversine_distance(&projected_point);
1557
1558 if distance < best_distance {
1559 best_distance = distance;
1560 best_segment_idx = idx;
1561 }
1562 }
1563 }
1564
1565 let best_segment = &path.segments[best_segment_idx];
1567 let best_netelement = netelement_map[best_segment.netelement_id.as_str()];
1568
1569 let projected_point =
1570 project_point_onto_linestring(&gnss_point, &best_netelement.geometry)?;
1571
1572 let distance_along =
1574 calculate_measure_along_linestring(&best_netelement.geometry, &projected_point)?;
1575 let total_length = best_netelement.geometry.haversine_length();
1576
1577 let intrinsic = if total_length > 0.0 {
1578 distance_along / total_length
1579 } else {
1580 0.0
1581 };
1582
1583 if !(0.0..=1.0).contains(&intrinsic) {
1585 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1586 reason: format!(
1587 "Intrinsic coordinate {} outside valid range [0, 1]",
1588 intrinsic
1589 ),
1590 });
1591 }
1592
1593 projected_positions.push(crate::models::ProjectedPosition::with_intrinsic(
1595 gnss.clone(),
1596 projected_point,
1597 best_netelement.id.clone(),
1598 distance_along,
1599 best_distance,
1600 gnss.crs.clone(),
1601 intrinsic,
1602 ));
1603 }
1604
1605 Ok(projected_positions)
1606}