1use crate::errors::ProjectionError;
41use crate::models::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
80impl PathResult {
81 pub fn new(
83 path: Option<TrainPath>,
84 mode: PathCalculationMode,
85 projected_positions: Vec<crate::models::ProjectedPosition>,
86 warnings: Vec<String>,
87 ) -> Self {
88 Self {
89 path,
90 mode,
91 projected_positions,
92 warnings,
93 debug_info: None,
94 }
95 }
96
97 pub fn with_debug_info(
99 path: Option<TrainPath>,
100 mode: PathCalculationMode,
101 projected_positions: Vec<crate::models::ProjectedPosition>,
102 warnings: Vec<String>,
103 debug_info: DebugInfo,
104 ) -> Self {
105 Self {
106 path,
107 mode,
108 projected_positions,
109 warnings,
110 debug_info: Some(debug_info),
111 }
112 }
113
114 pub fn has_debug_info(&self) -> bool {
116 self.debug_info.is_some()
117 }
118
119 pub fn is_topology_based(&self) -> bool {
121 self.mode == PathCalculationMode::TopologyBased
122 }
123
124 pub fn is_fallback(&self) -> bool {
126 self.mode == PathCalculationMode::FallbackIndependent
127 }
128
129 pub fn has_path(&self) -> bool {
131 self.path.is_some()
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct DebugInfo {
141 pub candidate_paths: Vec<CandidatePath>,
143
144 pub position_candidates: Vec<PositionCandidates>,
146
147 pub decision_tree: Vec<PathDecision>,
149
150 pub netelement_probabilities: Vec<NetelementProbabilityInfo>,
152
153 pub transition_probabilities: Vec<TransitionProbabilityEntry>,
155
156 pub sanity_decisions: Vec<viterbi::SanityDecision>,
158
159 pub gap_fills: Vec<viterbi::GapFill>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct NetelementProbabilityInfo {
166 pub netelement_id: String,
168
169 pub avg_emission_probability: f64,
171
172 pub position_count: usize,
174
175 pub geometry_coords: Vec<Vec<f64>>,
177
178 pub in_viterbi_path: bool,
180
181 pub is_bridge: bool,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CandidatePath {
188 pub id: String,
190
191 pub direction: String,
193
194 pub segment_ids: Vec<String>,
196
197 pub segment_probabilities: Vec<f64>,
199
200 pub probability: f64,
202
203 pub selected: bool,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PositionCandidates {
210 pub position_index: usize,
212
213 pub timestamp: String,
215
216 pub coordinates: (f64, f64),
218
219 pub candidates: Vec<CandidateInfo>,
221
222 pub selected_netelement: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct CandidateInfo {
229 pub netelement_id: String,
231
232 pub distance: f64,
234
235 pub heading_difference: Option<f64>,
237
238 pub distance_probability: f64,
240
241 pub heading_probability: Option<f64>,
243
244 pub combined_probability: f64,
246
247 pub status: String,
249
250 pub projected_lat: f64,
252
253 pub projected_lon: f64,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct TransitionProbabilityEntry {
260 pub from_step: usize,
262 pub to_step: usize,
264 pub from_netelement_id: String,
266 pub to_netelement_id: String,
268 pub transition_probability: f64,
270 pub is_viterbi_chosen: bool,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct PathDecision {
277 pub step: usize,
279
280 pub decision_type: String,
282
283 pub current_segment: String,
285
286 pub options: Vec<String>,
288
289 pub option_probabilities: Vec<f64>,
291
292 pub chosen_option: String,
294
295 pub reason: String,
297}
298
299impl DebugInfo {
300 pub fn new() -> Self {
302 Self::default()
303 }
304
305 pub fn add_candidate_path(&mut self, path: CandidatePath) {
307 self.candidate_paths.push(path);
308 }
309
310 pub fn add_position_candidates(&mut self, candidates: PositionCandidates) {
312 self.position_candidates.push(candidates);
313 }
314
315 pub fn add_decision(&mut self, decision: PathDecision) {
317 self.decision_tree.push(decision);
318 }
319
320 pub fn to_json(&self) -> Result<String, serde_json::Error> {
322 serde_json::to_string_pretty(self)
323 }
324
325 pub fn is_empty(&self) -> bool {
327 self.candidate_paths.is_empty()
328 && self.position_candidates.is_empty()
329 && self.decision_tree.is_empty()
330 && self.netelement_probabilities.is_empty()
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PathConfig {
358 pub distance_scale: f64,
361
362 pub heading_scale: f64,
365
366 pub cutoff_distance: f64,
368
369 pub heading_cutoff: f64,
372
373 pub probability_threshold: f64,
375
376 pub resampling_distance: Option<f64>,
378
379 pub max_candidates: usize,
381
382 pub path_only: bool,
385
386 pub debug_mode: bool,
389
390 pub beta: f64,
394
395 pub edge_zone_distance: f64,
400
401 pub turn_scale: f64,
406}
407
408impl PathConfig {
409 #[allow(clippy::too_many_arguments)]
411 pub fn new(
412 distance_scale: f64,
413 heading_scale: f64,
414 cutoff_distance: f64,
415 heading_cutoff: f64,
416 probability_threshold: f64,
417 resampling_distance: Option<f64>,
418 max_candidates: usize,
419 path_only: bool,
420 debug_mode: bool,
421 beta: f64,
422 edge_zone_distance: f64,
423 turn_scale: f64,
424 ) -> Result<Self, ProjectionError> {
425 let config = Self {
426 distance_scale,
427 heading_scale,
428 cutoff_distance,
429 heading_cutoff,
430 probability_threshold,
431 resampling_distance,
432 max_candidates,
433 path_only,
434 debug_mode,
435 beta,
436 edge_zone_distance,
437 turn_scale,
438 };
439
440 config.validate()?;
441 Ok(config)
442 }
443
444 fn validate(&self) -> Result<(), ProjectionError> {
446 if self.distance_scale <= 0.0 {
447 return Err(ProjectionError::InvalidGeometry(
448 "distance_scale must be positive".to_string(),
449 ));
450 }
451
452 if self.heading_scale <= 0.0 {
453 return Err(ProjectionError::InvalidGeometry(
454 "heading_scale must be positive".to_string(),
455 ));
456 }
457
458 if self.cutoff_distance <= 0.0 {
459 return Err(ProjectionError::InvalidGeometry(
460 "cutoff_distance must be positive".to_string(),
461 ));
462 }
463
464 if !(0.0..=90.0).contains(&self.heading_cutoff) {
465 return Err(ProjectionError::InvalidGeometry(
466 "heading_cutoff must be in [0, 90]".to_string(),
467 ));
468 }
469
470 if !(0.0..=1.0).contains(&self.probability_threshold) {
471 return Err(ProjectionError::InvalidGeometry(
472 "probability_threshold must be in [0, 1]".to_string(),
473 ));
474 }
475
476 if let Some(resampling) = self.resampling_distance {
477 if resampling <= 0.0 {
478 return Err(ProjectionError::InvalidGeometry(
479 "resampling_distance must be positive".to_string(),
480 ));
481 }
482 }
483
484 if self.max_candidates == 0 {
485 return Err(ProjectionError::InvalidGeometry(
486 "max_candidates must be at least 1".to_string(),
487 ));
488 }
489
490 if self.beta <= 0.0 {
491 return Err(ProjectionError::InvalidGeometry(
492 "beta must be positive".to_string(),
493 ));
494 }
495
496 if self.edge_zone_distance <= 0.0 {
497 return Err(ProjectionError::InvalidGeometry(
498 "edge_zone_distance must be positive".to_string(),
499 ));
500 }
501
502 if self.turn_scale <= 0.0 {
503 return Err(ProjectionError::InvalidGeometry(
504 "turn_scale must be positive".to_string(),
505 ));
506 }
507
508 Ok(())
509 }
510
511 pub fn builder() -> PathConfigBuilder {
513 PathConfigBuilder::default()
514 }
515}
516
517impl Default for PathConfig {
518 fn default() -> Self {
534 Self {
535 distance_scale: 10.0,
536 heading_scale: 2.0,
537 cutoff_distance: 500.0,
538 heading_cutoff: 10.0,
539 probability_threshold: 0.02,
540 resampling_distance: None,
541 max_candidates: 3,
542 path_only: false,
543 debug_mode: false,
544 beta: 50.0,
545 edge_zone_distance: 50.0,
546 turn_scale: 30.0,
547 }
548 }
549}
550
551#[derive(Debug, Clone)]
572pub struct PathConfigBuilder {
573 distance_scale: f64,
574 heading_scale: f64,
575 cutoff_distance: f64,
576 heading_cutoff: f64,
577 probability_threshold: f64,
578 resampling_distance: Option<f64>,
579 max_candidates: usize,
580 path_only: bool,
581 debug_mode: bool,
582 beta: f64,
583 edge_zone_distance: f64,
584 turn_scale: f64,
585}
586
587impl Default for PathConfigBuilder {
588 fn default() -> Self {
589 let defaults = PathConfig::default();
590 Self {
591 distance_scale: defaults.distance_scale,
592 heading_scale: defaults.heading_scale,
593 cutoff_distance: defaults.cutoff_distance,
594 heading_cutoff: defaults.heading_cutoff,
595 probability_threshold: defaults.probability_threshold,
596 resampling_distance: defaults.resampling_distance,
597 max_candidates: defaults.max_candidates,
598 path_only: defaults.path_only,
599 debug_mode: defaults.debug_mode,
600 beta: defaults.beta,
601 edge_zone_distance: defaults.edge_zone_distance,
602 turn_scale: defaults.turn_scale,
603 }
604 }
605}
606
607impl PathConfigBuilder {
608 pub fn distance_scale(mut self, value: f64) -> Self {
610 self.distance_scale = value;
611 self
612 }
613
614 pub fn heading_scale(mut self, value: f64) -> Self {
616 self.heading_scale = value;
617 self
618 }
619
620 pub fn cutoff_distance(mut self, value: f64) -> Self {
622 self.cutoff_distance = value;
623 self
624 }
625
626 pub fn heading_cutoff(mut self, value: f64) -> Self {
628 self.heading_cutoff = value;
629 self
630 }
631
632 pub fn probability_threshold(mut self, value: f64) -> Self {
634 self.probability_threshold = value;
635 self
636 }
637
638 pub fn resampling_distance(mut self, value: Option<f64>) -> Self {
640 self.resampling_distance = value;
641 self
642 }
643
644 pub fn max_candidates(mut self, value: usize) -> Self {
646 self.max_candidates = value;
647 self
648 }
649
650 pub fn path_only(mut self, value: bool) -> Self {
652 self.path_only = value;
653 self
654 }
655
656 pub fn debug_mode(mut self, value: bool) -> Self {
658 self.debug_mode = value;
659 self
660 }
661
662 pub fn beta(mut self, value: f64) -> Self {
664 self.beta = value;
665 self
666 }
667
668 pub fn edge_zone_distance(mut self, value: f64) -> Self {
670 self.edge_zone_distance = value;
671 self
672 }
673
674 pub fn turn_scale(mut self, value: f64) -> Self {
676 self.turn_scale = value;
677 self
678 }
679
680 pub fn build(self) -> Result<PathConfig, ProjectionError> {
682 PathConfig::new(
683 self.distance_scale,
684 self.heading_scale,
685 self.cutoff_distance,
686 self.heading_cutoff,
687 self.probability_threshold,
688 self.resampling_distance,
689 self.max_candidates,
690 self.path_only,
691 self.debug_mode,
692 self.beta,
693 self.edge_zone_distance,
694 self.turn_scale,
695 )
696 }
697}
698
699pub mod candidate;
700pub mod debug;
701pub mod graph;
702pub mod probability;
703pub mod spacing;
704pub mod viterbi;
705
706#[cfg(test)]
707mod tests;
708
709pub use candidate::*;
711pub use debug::{
712 export_all_debug_info, export_gap_fills, export_hmm_candidate_netelements,
713 export_hmm_emission_probabilities, export_hmm_selected_path, export_hmm_viterbi_trace,
714 export_path_sanity_decisions,
715};
716pub use graph::{
717 build_topology_graph, cached_shortest_path_distance, shortest_path_distance,
718 shortest_path_route, validate_netrelation_references, NetelementSide, ShortestPathCache,
719};
720pub use probability::*;
721pub use spacing::{calculate_mean_spacing, select_resampled_subset};
722pub use viterbi::{
723 build_path_from_viterbi, fill_path_gaps, validate_path_navigability, viterbi_decode, GapFill,
724 SanityDecision, ViterbiResult, ViterbiSubsequence,
725};
726
727pub use PathCalculationMode::{FallbackIndependent, TopologyBased};
729pub fn calculate_train_path(
802 gnss_positions: &[crate::models::GnssPosition],
803 netelements: &[crate::models::Netelement],
804 netrelations: &[crate::models::NetRelation],
805 config: &PathConfig,
806) -> Result<PathResult, crate::errors::ProjectionError> {
807 use crate::path::candidate::find_candidate_netelements;
808 use crate::path::probability::{
809 calculate_combined_probability, calculate_distance_probability,
810 calculate_heading_probability,
811 };
812 use std::collections::HashMap;
813
814 if netelements.is_empty() {
816 return Err(crate::errors::ProjectionError::EmptyNetwork);
817 }
818 if gnss_positions.is_empty() {
819 return Err(crate::errors::ProjectionError::PathCalculationFailed {
820 reason: "No GNSS positions provided".to_string(),
821 });
822 }
823
824 let mut debug_info = if config.debug_mode {
826 Some(DebugInfo::new())
827 } else {
828 None
829 };
830
831 let (working_positions, resampling_applied) = if let Some(resample_dist) =
833 config.resampling_distance
834 {
835 let indices = crate::path::spacing::select_resampled_subset(gnss_positions, resample_dist);
836 let subset: Vec<_> = indices.iter().map(|&i| &gnss_positions[i]).collect();
837 (subset, indices.len() < gnss_positions.len())
838 } else {
839 (gnss_positions.iter().collect(), false)
841 };
842
843 if config.path_only {
845 }
849
850 let mut position_candidates: Vec<Vec<crate::path::candidate::CandidateNetElement>> = Vec::new();
853
854 for gnss in &working_positions {
855 let candidates = find_candidate_netelements(
856 gnss,
857 netelements,
858 config.cutoff_distance,
859 config.max_candidates,
860 )?;
861 position_candidates.push(candidates);
862 }
863
864 let netelement_index: HashMap<String, usize> = netelements
868 .iter()
869 .enumerate()
870 .map(|(idx, ne)| (ne.id.clone(), idx))
871 .collect();
872
873 let estimated_headings =
876 crate::path::candidate::estimate_headings_from_neighbors(&working_positions);
877
878 let mut position_probabilities: Vec<HashMap<usize, f64>> = Vec::new(); for (pos_idx, candidates) in position_candidates.iter().enumerate() {
881 let mut probs = HashMap::new();
882 let gnss = working_positions[pos_idx]; let mut debug_candidates: Vec<CandidateInfo> = Vec::new();
886
887 for candidate in candidates {
888 let netelement_idx =
889 netelement_index
890 .get(&candidate.netelement_id)
891 .ok_or_else(|| crate::errors::ProjectionError::PathCalculationFailed {
892 reason: format!(
893 "Netelement {} not found in index",
894 candidate.netelement_id
895 ),
896 })?;
897
898 let dist_prob =
900 calculate_distance_probability(candidate.distance_meters, config.distance_scale);
901
902 let effective_heading = gnss.heading.or(estimated_headings[pos_idx]);
905
906 let heading_diff_value = if let Some(gnss_heading) = effective_heading {
907 use crate::path::candidate::{calculate_heading_at_point, heading_difference};
908 let netelement = &netelements[*netelement_idx];
909 let netelement_heading =
910 calculate_heading_at_point(&candidate.projected_point, &netelement.geometry)?;
911 Some(heading_difference(gnss_heading, netelement_heading))
912 } else {
913 None
914 };
915
916 let heading_prob = if let Some(heading_diff) = heading_diff_value {
917 calculate_heading_probability(
918 heading_diff,
919 config.heading_scale,
920 config.heading_cutoff,
921 )
922 } else {
923 1.0 };
925
926 let combined = calculate_combined_probability(dist_prob, heading_prob);
928 probs.insert(*netelement_idx, combined);
929
930 if config.debug_mode {
932 debug_candidates.push(CandidateInfo {
933 netelement_id: candidate.netelement_id.clone(),
934 distance: candidate.distance_meters,
935 heading_difference: heading_diff_value,
936 distance_probability: dist_prob,
937 heading_probability: if heading_diff_value.is_some() {
938 Some(heading_prob)
939 } else {
940 None
941 },
942 combined_probability: combined,
943 status: if combined >= config.probability_threshold {
944 "accepted".to_string()
945 } else {
946 "below_threshold".to_string()
947 },
948 projected_lat: candidate.projected_point.y(),
949 projected_lon: candidate.projected_point.x(),
950 });
951 }
952 }
953
954 if let Some(ref mut debug) = debug_info {
956 let selected = probs
957 .iter()
958 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
959 .map(|(&idx, _)| netelements[idx].id.clone());
960
961 debug.add_position_candidates(PositionCandidates {
962 position_index: pos_idx,
963 timestamp: gnss.timestamp.to_rfc3339(),
964 coordinates: (gnss.latitude, gnss.longitude),
965 candidates: debug_candidates,
966 selected_netelement: selected,
967 });
968 }
969
970 position_probabilities.push(probs);
971 }
972
973 let emission_probs: Vec<Vec<f64>> = position_candidates
980 .iter()
981 .enumerate()
982 .map(|(t, cands)| {
983 cands
984 .iter()
985 .map(|c| {
986 netelement_index
987 .get(&c.netelement_id)
988 .and_then(|idx| position_probabilities[t].get(idx))
989 .copied()
990 .unwrap_or(0.0)
991 })
992 .collect()
993 })
994 .collect();
995
996 let (topo_graph, node_map) =
998 crate::path::graph::build_topology_graph(netelements, netrelations)?;
999 let mut sp_cache = crate::path::graph::ShortestPathCache::new();
1000
1001 let viterbi_result = crate::path::viterbi::viterbi_decode(
1003 &position_candidates,
1004 &emission_probs,
1005 netelements,
1006 &netelement_index,
1007 &topo_graph,
1008 &node_map,
1009 &mut sp_cache,
1010 config,
1011 )?;
1012
1013 let path_segments = crate::path::viterbi::build_path_from_viterbi(
1015 &viterbi_result,
1016 &position_candidates,
1017 netelements,
1018 &netelement_index,
1019 &topo_graph,
1020 &node_map,
1021 &mut sp_cache,
1022 )?;
1023
1024 let (path_segments, nav_warnings, sanity_decisions) =
1027 crate::path::viterbi::validate_path_navigability(
1028 path_segments,
1029 netelements,
1030 &netelement_index,
1031 &topo_graph,
1032 &node_map,
1033 &mut sp_cache,
1034 );
1035
1036 if let Some(ref mut debug) = debug_info {
1038 debug.sanity_decisions = sanity_decisions;
1039 }
1040
1041 let (path_segments, gap_warnings, gap_fills) = crate::path::viterbi::fill_path_gaps(
1044 path_segments,
1045 &netelement_index,
1046 &topo_graph,
1047 &node_map,
1048 &mut sp_cache,
1049 );
1050
1051 if let Some(ref mut debug) = debug_info {
1053 debug.gap_fills = gap_fills;
1054 }
1055
1056 let path_probability = if viterbi_result.subsequences.is_empty() {
1058 0.0
1059 } else {
1060 let total_log: f64 = viterbi_result
1062 .subsequences
1063 .iter()
1064 .map(|s| s.log_probability)
1065 .sum();
1066 let total_states: usize = viterbi_result
1067 .subsequences
1068 .iter()
1069 .map(|s| s.states.len())
1070 .sum();
1071 if total_states > 0 {
1072 (total_log / total_states as f64).exp().min(1.0)
1073 } else {
1074 0.0
1075 }
1076 };
1077
1078 let final_path = if path_segments.is_empty() {
1079 None
1080 } else {
1081 Some((path_segments, path_probability))
1082 };
1083
1084 if let Some(ref mut debug) = debug_info {
1086 let mut viterbi_state_ne_ids: std::collections::HashSet<String> =
1088 std::collections::HashSet::new();
1089 for subseq in &viterbi_result.subsequences {
1090 for &(t, c_idx) in &subseq.states {
1091 viterbi_state_ne_ids.insert(position_candidates[t][c_idx].netelement_id.clone());
1092 }
1093 }
1094
1095 let mut final_path_ne_ids: std::collections::HashSet<String> =
1098 std::collections::HashSet::new();
1099 if let Some((ref segments, _)) = final_path {
1100 for seg in segments {
1101 final_path_ne_ids.insert(seg.netelement_id.clone());
1102 }
1103 }
1104
1105 let mut ne_emission_sums: HashMap<String, (f64, usize)> = HashMap::new();
1107 for (t, cands) in position_candidates.iter().enumerate() {
1108 for (c_idx, cand) in cands.iter().enumerate() {
1109 let emission = emission_probs[t][c_idx];
1110 let entry = ne_emission_sums
1111 .entry(cand.netelement_id.clone())
1112 .or_insert((0.0, 0));
1113 entry.0 += emission;
1114 entry.1 += 1;
1115 }
1116 }
1117
1118 for (ne_id, (sum, count)) in &ne_emission_sums {
1119 let geometry_coords: Vec<Vec<f64>> = netelements
1120 .iter()
1121 .find(|ne| ne.id == *ne_id)
1122 .map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
1123 .unwrap_or_default();
1124
1125 debug
1126 .netelement_probabilities
1127 .push(NetelementProbabilityInfo {
1128 netelement_id: ne_id.clone(),
1129 avg_emission_probability: if *count > 0 { sum / *count as f64 } else { 0.0 },
1130 position_count: *count,
1131 geometry_coords,
1132 in_viterbi_path: final_path_ne_ids.contains(ne_id),
1133 is_bridge: final_path_ne_ids.contains(ne_id)
1134 && !viterbi_state_ne_ids.contains(ne_id),
1135 });
1136 }
1137
1138 for ne_id in &final_path_ne_ids {
1140 if !ne_emission_sums.contains_key(ne_id) {
1141 let geometry_coords: Vec<Vec<f64>> = netelements
1142 .iter()
1143 .find(|ne| ne.id == *ne_id)
1144 .map(|ne| ne.geometry.0.iter().map(|c| vec![c.x, c.y]).collect())
1145 .unwrap_or_default();
1146
1147 debug
1148 .netelement_probabilities
1149 .push(NetelementProbabilityInfo {
1150 netelement_id: ne_id.clone(),
1151 avg_emission_probability: 0.0,
1152 position_count: 0,
1153 geometry_coords,
1154 in_viterbi_path: true,
1155 is_bridge: true,
1156 });
1157 }
1158 }
1159
1160 for (sub_idx, subseq) in viterbi_result.subsequences.iter().enumerate() {
1162 let mut segment_ids: Vec<String> = Vec::new();
1164 let mut segment_probs: Vec<f64> = Vec::new();
1165 for &(t, c_idx) in &subseq.states {
1166 let ne_id = &position_candidates[t][c_idx].netelement_id;
1167 let emission = emission_probs[t][c_idx];
1168 if segment_ids.last() != Some(ne_id) {
1169 segment_ids.push(ne_id.clone());
1170 segment_probs.push(emission);
1171 }
1172 }
1173
1174 debug.add_candidate_path(CandidatePath {
1175 id: format!("viterbi_{}", sub_idx),
1176 direction: "viterbi".to_string(),
1177 segment_ids,
1178 segment_probabilities: segment_probs,
1179 probability: subseq.log_probability.exp().min(1.0),
1180 selected: true,
1181 });
1182 }
1183
1184 for subseq in &viterbi_result.subsequences {
1186 for (state_idx, &(t, c_idx)) in subseq.states.iter().enumerate() {
1187 let chosen_ne = &position_candidates[t][c_idx].netelement_id;
1188 let options: Vec<String> = position_candidates[t]
1189 .iter()
1190 .map(|c| c.netelement_id.clone())
1191 .collect();
1192 let option_probs: Vec<f64> = emission_probs[t].clone();
1193
1194 let decision_type = if state_idx == 0 {
1195 "viterbi_init".to_string()
1196 } else {
1197 "viterbi_transition".to_string()
1198 };
1199
1200 debug.add_decision(PathDecision {
1201 step: t,
1202 decision_type,
1203 current_segment: chosen_ne.clone(),
1204 options,
1205 option_probabilities: option_probs,
1206 chosen_option: chosen_ne.clone(),
1207 reason: format!(
1208 "Viterbi best state (log_prob: {:.4})",
1209 subseq.log_probability
1210 ),
1211 });
1212 }
1213 }
1214
1215 let chosen_pairs: std::collections::HashSet<(usize, usize, usize, usize)> = viterbi_result
1217 .subsequences
1218 .iter()
1219 .flat_map(|subseq| subseq.states.windows(2))
1220 .map(|w| (w[0].0, w[0].1, w[1].0, w[1].1))
1221 .collect();
1222
1223 for &(from_t, from_idx, to_t, to_idx, prob) in &viterbi_result.transition_records {
1224 debug
1225 .transition_probabilities
1226 .push(TransitionProbabilityEntry {
1227 from_step: from_t,
1228 to_step: to_t,
1229 from_netelement_id: position_candidates[from_t][from_idx].netelement_id.clone(),
1230 to_netelement_id: position_candidates[to_t][to_idx].netelement_id.clone(),
1231 transition_probability: prob,
1232 is_viterbi_chosen: chosen_pairs.contains(&(from_t, from_idx, to_t, to_idx)),
1233 });
1234 }
1235 }
1236
1237 let train_path = if let Some((segments, prob)) = final_path {
1239 use chrono::Utc;
1240 let timestamp = gnss_positions
1242 .first()
1243 .map(|p| p.timestamp.with_timezone(&Utc));
1244 Some(crate::models::TrainPath::new(
1245 segments, prob, timestamp, None, )?)
1247 } else {
1248 None
1249 };
1250
1251 let mut warnings = Vec::new();
1253 warnings.extend(nav_warnings);
1254 warnings.extend(gap_warnings);
1255 if config.path_only {
1256 warnings.push("Path-only mode enabled: skipping projection phase".to_string());
1257 }
1258 if resampling_applied {
1259 warnings.push(format!(
1260 "Resampling applied: used {} of {} positions for path calculation",
1261 working_positions.len(),
1262 gnss_positions.len()
1263 ));
1264 }
1265
1266 if train_path.is_none() {
1268 warnings.push("No continuous path found using topology-based calculation".to_string());
1269 warnings.push("Viterbi decoding produced no valid path".to_string());
1270
1271 tracing::warn!(
1273 gnss_count = gnss_positions.len(),
1274 netelement_count = netelements.len(),
1275 viterbi_subsequences = viterbi_result.subsequences.len(),
1276 "Path calculation failed, falling back to independent projection"
1277 );
1278
1279 let fallback_positions = if config.path_only {
1281 warnings.push("Path-only mode: skipping fallback projection".to_string());
1283 Vec::new()
1284 } else {
1285 warnings.push("Falling back to independent nearest-segment projection".to_string());
1286
1287 use crate::projection::{find_nearest_netelement, NetworkIndex};
1290 let network_index = NetworkIndex::new(netelements.to_vec())?;
1291
1292 let mut positions = Vec::new();
1293 for gnss in gnss_positions {
1294 use geo::Point;
1296 let gnss_point = Point::new(gnss.longitude, gnss.latitude);
1297
1298 if let Ok(netelement_idx) = find_nearest_netelement(&gnss_point, &network_index) {
1299 let nearest = &network_index.netelements()[netelement_idx];
1300
1301 use crate::projection::project_point_onto_linestring;
1302 let projected_point =
1303 project_point_onto_linestring(&gnss_point, &nearest.geometry)?;
1304
1305 use crate::projection::calculate_measure_along_linestring;
1306 let measure =
1307 calculate_measure_along_linestring(&nearest.geometry, &projected_point)?;
1308
1309 use geo::HaversineDistance;
1311 let distance = gnss_point.haversine_distance(&projected_point);
1312
1313 let projected = crate::models::ProjectedPosition::new(
1314 gnss.clone(),
1315 projected_point,
1316 nearest.id.clone(),
1317 measure,
1318 distance,
1319 gnss.crs.clone(),
1320 );
1321 positions.push(projected);
1322 }
1323 }
1324 positions
1325 };
1326
1327 let mut result = PathResult::new(
1329 None, PathCalculationMode::FallbackIndependent,
1331 fallback_positions,
1332 warnings,
1333 );
1334 result.debug_info = debug_info;
1335 return Ok(result);
1336 }
1337
1338 let mut result = PathResult::new(
1340 train_path,
1341 PathCalculationMode::TopologyBased,
1342 vec![], warnings,
1344 );
1345 result.debug_info = debug_info;
1346 Ok(result)
1347}
1348
1349pub fn project_onto_path(
1398 gnss_positions: &[crate::models::GnssPosition],
1399 path: &crate::models::TrainPath,
1400 netelements: &[crate::models::Netelement],
1401 _config: &PathConfig,
1402) -> Result<Vec<crate::models::ProjectedPosition>, crate::errors::ProjectionError> {
1403 use crate::projection::geom::{
1404 calculate_measure_along_linestring, project_point_onto_linestring,
1405 };
1406 use geo::{HaversineLength, Point};
1407 use std::collections::HashMap;
1408
1409 if gnss_positions.is_empty() {
1411 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1412 reason: "No GNSS positions provided".to_string(),
1413 });
1414 }
1415
1416 if path.segments.is_empty() {
1417 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1418 reason: "Path has no segments".to_string(),
1419 });
1420 }
1421
1422 let netelement_map: HashMap<_, _> = netelements.iter().map(|ne| (ne.id.as_str(), ne)).collect();
1424
1425 for segment in &path.segments {
1427 if !netelement_map.contains_key(segment.netelement_id.as_str()) {
1428 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1429 reason: format!(
1430 "Netelement {} in path not found in network",
1431 segment.netelement_id
1432 ),
1433 });
1434 }
1435 }
1436
1437 let mut projected_positions = Vec::with_capacity(gnss_positions.len());
1438
1439 for gnss in gnss_positions {
1441 let mut best_distance = f64::MAX;
1443 let mut best_segment_idx = 0;
1444 let gnss_point = Point::new(gnss.longitude, gnss.latitude);
1445
1446 for (idx, segment) in path.segments.iter().enumerate() {
1447 let netelement = netelement_map[segment.netelement_id.as_str()];
1448
1449 if let Ok(projected_point) =
1451 project_point_onto_linestring(&gnss_point, &netelement.geometry)
1452 {
1453 use geo::HaversineDistance;
1454 let distance = gnss_point.haversine_distance(&projected_point);
1455
1456 if distance < best_distance {
1457 best_distance = distance;
1458 best_segment_idx = idx;
1459 }
1460 }
1461 }
1462
1463 let best_segment = &path.segments[best_segment_idx];
1465 let best_netelement = netelement_map[best_segment.netelement_id.as_str()];
1466
1467 let projected_point =
1468 project_point_onto_linestring(&gnss_point, &best_netelement.geometry)?;
1469
1470 let distance_along =
1472 calculate_measure_along_linestring(&best_netelement.geometry, &projected_point)?;
1473 let total_length = best_netelement.geometry.haversine_length();
1474
1475 let intrinsic = if total_length > 0.0 {
1476 distance_along / total_length
1477 } else {
1478 0.0
1479 };
1480
1481 if !(0.0..=1.0).contains(&intrinsic) {
1483 return Err(crate::errors::ProjectionError::PathCalculationFailed {
1484 reason: format!(
1485 "Intrinsic coordinate {} outside valid range [0, 1]",
1486 intrinsic
1487 ),
1488 });
1489 }
1490
1491 projected_positions.push(crate::models::ProjectedPosition::with_intrinsic(
1493 gnss.clone(),
1494 projected_point,
1495 best_netelement.id.clone(),
1496 distance_along,
1497 best_distance,
1498 gnss.crs.clone(),
1499 intrinsic,
1500 ));
1501 }
1502
1503 Ok(projected_positions)
1504}