Skip to main content

tp_lib_core/
path.rs

1//! Train path calculation module
2//!
3//! This module provides probabilistic train path calculation through rail networks
4//! using GNSS data and network topology (netrelations).
5//!
6//! # Overview
7//!
8//! The path calculation algorithm:
9//! 1. Identifies candidate netelements for each GNSS position
10//! 2. Calculates probabilities based on distance and heading alignment
11//! 3. Constructs paths bidirectionally (forward and backward)
12//! 4. Validates path continuity and navigability
13//! 5. Selects the highest probability path
14//!
15//! # Examples
16//!
17//! ```no_run
18//! use tp_lib_core::{calculate_train_path, PathConfig, GnssPosition, Netelement, NetRelation};
19//!
20//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
21//! let gnss_positions: Vec<GnssPosition> = vec![]; // Load GNSS data
22//! let netelements: Vec<Netelement> = vec![]; // Load network
23//! let netrelations: Vec<NetRelation> = vec![]; // Load topology
24//! let config = PathConfig::default();
25//!
26//! let result = calculate_train_path(
27//!     &gnss_positions,
28//!     &netelements,
29//!     &netrelations,
30//!     &config,
31//! )?;
32//!
33//! if result.path.is_some() {
34//!     println!("Path calculated successfully");
35//! }
36//! # Ok(())
37//! # }
38//! ```
39
40use crate::errors::ProjectionError;
41use crate::models::{DetectionRecord, ResolvedAnchor, TrainPath};
42use serde::{Deserialize, Serialize};
43
44/// Path calculation mode indicating which algorithm was used
45///
46/// Determines whether the path was calculated using network topology
47/// or fell back to independent position-by-position projection.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49pub enum PathCalculationMode {
50    /// Path calculated using network topology and navigability rules
51    TopologyBased,
52
53    /// Fallback mode: positions projected independently without topology
54    FallbackIndependent,
55}
56
57/// Result of train path calculation
58///
59/// Contains the calculated path (if any), mode used, projected positions,
60/// and any warnings encountered during calculation.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PathResult {
63    /// The calculated path, None if calculation failed
64    pub path: Option<TrainPath>,
65
66    /// Mode used for calculation
67    pub mode: PathCalculationMode,
68
69    /// Projected GNSS positions onto the path
70    pub projected_positions: Vec<crate::models::ProjectedPosition>,
71
72    /// Warnings encountered during calculation
73    pub warnings: Vec<String>,
74
75    /// Debug information collected during calculation (only populated when debug_mode=true)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub debug_info: Option<DebugInfo>,
78
79    /// Detection provenance records (Feature 004): per-detection status
80    /// (`Applied`, `Resolved`, `Discarded`) emitted by the detection pipeline.
81    /// Empty when no detections were supplied.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub detection_provenance: Vec<DetectionRecord>,
84}
85
86impl PathResult {
87    /// Create a new path result
88    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    /// Create a new path result with debug info
105    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    /// Check if debug info is available
123    pub fn has_debug_info(&self) -> bool {
124        self.debug_info.is_some()
125    }
126
127    /// Check if topology-based calculation was used
128    pub fn is_topology_based(&self) -> bool {
129        self.mode == PathCalculationMode::TopologyBased
130    }
131
132    /// Check if fallback mode was used
133    pub fn is_fallback(&self) -> bool {
134        self.mode == PathCalculationMode::FallbackIndependent
135    }
136
137    /// Check if path calculation succeeded
138    pub fn has_path(&self) -> bool {
139        self.path.is_some()
140    }
141}
142
143/// Debug information for path calculation (US7: T153)
144///
145/// Contains intermediate results for troubleshooting and parameter tuning.
146/// Collected when `PathConfig::debug_mode` is enabled.
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148pub struct DebugInfo {
149    /// Viterbi path subsequences evaluated during path calculation
150    pub candidate_paths: Vec<CandidatePath>,
151
152    /// Candidates considered for each GNSS position
153    pub position_candidates: Vec<PositionCandidates>,
154
155    /// Viterbi decision trace showing state selection per timestep
156    pub decision_tree: Vec<PathDecision>,
157
158    /// Netelement probability information (emission probs + Viterbi membership)
159    pub netelement_probabilities: Vec<NetelementProbabilityInfo>,
160
161    /// Transition probabilities between consecutive candidates (HMM state transitions)
162    pub transition_probabilities: Vec<TransitionProbabilityEntry>,
163
164    /// Sanity check decisions for each consecutive segment pair (post-Viterbi validation)
165    pub sanity_decisions: Vec<viterbi::SanityDecision>,
166
167    /// Gap-fill records for each consecutive segment pair after sanity validation
168    pub gap_fills: Vec<viterbi::GapFill>,
169}
170
171/// Probability information for a single netelement in the Viterbi result
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct NetelementProbabilityInfo {
174    /// Netelement ID
175    pub netelement_id: String,
176
177    /// Average emission probability across positions that matched this netelement
178    pub avg_emission_probability: f64,
179
180    /// Number of GNSS positions where this netelement was a candidate
181    pub position_count: usize,
182
183    /// Geometry coordinates as Vec of [lon, lat] pairs
184    pub geometry_coords: Vec<Vec<f64>>,
185
186    /// Whether this netelement appears in the final Viterbi path
187    pub in_viterbi_path: bool,
188
189    /// Whether this netelement is a bridge (inserted during post-processing)
190    pub is_bridge: bool,
191}
192
193/// Information about a candidate path evaluated during calculation
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct CandidatePath {
196    /// Unique identifier for this candidate path (e.g., "viterbi_0")
197    pub id: String,
198
199    /// Source of this path (e.g., "viterbi")
200    pub direction: String,
201
202    /// Netelement IDs in this candidate path
203    pub segment_ids: Vec<String>,
204
205    /// Per-segment probabilities, parallel to segment_ids
206    pub segment_probabilities: Vec<f64>,
207
208    /// Overall probability score for this path
209    pub probability: f64,
210
211    /// Whether this path was selected as the final result
212    pub selected: bool,
213}
214
215/// Candidates considered for a single GNSS position
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct PositionCandidates {
218    /// Index of the GNSS position (0-based)
219    pub position_index: usize,
220
221    /// Timestamp of the position (ISO 8601)
222    pub timestamp: String,
223
224    /// GNSS coordinates (lat, lon)
225    pub coordinates: (f64, f64),
226
227    /// Candidate netelements with their probabilities
228    pub candidates: Vec<CandidateInfo>,
229
230    /// ID of the netelement selected for this position
231    pub selected_netelement: Option<String>,
232}
233
234/// Information about a single candidate netelement
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CandidateInfo {
237    /// Netelement ID
238    pub netelement_id: String,
239
240    /// Distance from GNSS position to netelement (meters)
241    pub distance: f64,
242
243    /// Heading difference (degrees)
244    pub heading_difference: Option<f64>,
245
246    /// Distance-based probability component
247    pub distance_probability: f64,
248
249    /// Heading-based probability component
250    pub heading_probability: Option<f64>,
251
252    /// Combined probability
253    pub combined_probability: f64,
254
255    /// Why this candidate was included or excluded
256    pub status: String,
257
258    /// Projected point latitude (WGS84)
259    pub projected_lat: f64,
260
261    /// Projected point longitude (WGS84)
262    pub projected_lon: f64,
263}
264
265/// A single transition probability between two candidates at consecutive GNSS positions
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct TransitionProbabilityEntry {
268    /// Position index of the preceding observation
269    pub from_step: usize,
270    /// Position index of the current observation
271    pub to_step: usize,
272    /// Netelement of the preceding candidate
273    pub from_netelement_id: String,
274    /// Netelement of the current candidate
275    pub to_netelement_id: String,
276    /// Linear-scale transition probability \[0, 1\]
277    pub transition_probability: f64,
278    /// True if this (from, to) pair was chosen by the Viterbi algorithm
279    pub is_viterbi_chosen: bool,
280}
281
282/// A decision point in the Viterbi path decoding process
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct PathDecision {
285    /// Step number (position index in the GNSS sequence)
286    pub step: usize,
287
288    /// Type of decision (e.g., "viterbi_transition", "viterbi_break", "viterbi_init")
289    pub decision_type: String,
290
291    /// Netelement chosen at this step
292    pub current_segment: String,
293
294    /// Candidate netelements considered at this step
295    pub options: Vec<String>,
296
297    /// Log-probabilities for each candidate at this step
298    pub option_probabilities: Vec<f64>,
299
300    /// Which netelement was chosen (best Viterbi state)
301    pub chosen_option: String,
302
303    /// Reason for the choice
304    pub reason: String,
305}
306
307impl DebugInfo {
308    /// Create a new empty DebugInfo
309    pub fn new() -> Self {
310        Self::default()
311    }
312
313    /// Add a candidate path to the debug info
314    pub fn add_candidate_path(&mut self, path: CandidatePath) {
315        self.candidate_paths.push(path);
316    }
317
318    /// Add position candidates info
319    pub fn add_position_candidates(&mut self, candidates: PositionCandidates) {
320        self.position_candidates.push(candidates);
321    }
322
323    /// Add a decision to the tree
324    pub fn add_decision(&mut self, decision: PathDecision) {
325        self.decision_tree.push(decision);
326    }
327
328    /// Export debug info to JSON string
329    pub fn to_json(&self) -> Result<String, serde_json::Error> {
330        serde_json::to_string_pretty(self)
331    }
332
333    /// Check if any debug information was collected
334    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/// Configuration for train path calculation algorithm
343///
344/// Controls probability thresholds, distance cutoffs, and other parameters
345/// that affect path selection and candidate filtering.
346///
347/// # Examples
348///
349/// ```
350/// use tp_lib_core::PathConfig;
351///
352/// // Use default configuration
353/// let config = PathConfig::default();
354/// assert_eq!(config.distance_scale, 10.0);
355///
356/// // Create custom configuration
357/// let config = PathConfig::builder()
358///     .distance_scale(15.0)
359///     .heading_scale(3.0)
360///     .cutoff_distance(75.0)
361///     .build()
362///     .unwrap();
363/// ```
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct PathConfig {
366    /// Distance scale parameter for exponential decay (meters)
367    /// Controls how quickly probability decreases with distance
368    pub distance_scale: f64,
369
370    /// Heading scale parameter for exponential decay (degrees)
371    /// Controls how quickly probability decreases with heading difference
372    pub heading_scale: f64,
373
374    /// Maximum distance from GNSS position to consider netelement as candidate (meters)
375    pub cutoff_distance: f64,
376
377    /// Maximum heading difference to consider netelement as candidate (degrees)
378    /// Positions with larger heading differences are filtered out
379    pub heading_cutoff: f64,
380
381    /// Minimum probability threshold for including segment in path (0.0 to 1.0)
382    pub probability_threshold: f64,
383
384    /// Distance between resampled positions (meters), None to disable resampling
385    pub resampling_distance: Option<f64>,
386
387    /// Maximum number of candidate netelements per GNSS position
388    pub max_candidates: usize,
389
390    /// If true, only calculate path without projecting positions (US2: T098)
391    /// When true, PathResult.projected_positions will be empty
392    pub path_only: bool,
393
394    /// If true, collect and return debug information about path calculation (US7: T152)
395    /// Includes candidate paths, position candidates, and decision tree
396    pub debug_mode: bool,
397
398    /// Transition probability scale parameter (meters, Newson & Krumm β)
399    /// Controls tolerance for mismatch between route distance and great-circle distance.
400    /// Higher values are more forgiving of detours.
401    pub beta: f64,
402
403    /// Edge-zone distance threshold (meters)
404    /// Candidates whose projected point is farther than this from the nearest
405    /// netelement endpoint are considered interior and cannot transition to a
406    /// different netelement (transition probability = 0).
407    pub edge_zone_distance: f64,
408
409    /// Turn-angle scale parameter (degrees)
410    /// Penalises transitions that require a direction change at a netelement connection.
411    /// The penalty factor is exp(-turn_angle / turn_scale).
412    /// Lower values penalise turns more aggressively.
413    pub turn_scale: f64,
414
415    /// Resolved detection anchors injected as Viterbi constraints (Feature 004).
416    /// Each entry pins one or more GNSS indices to a specific netelement / intrinsic.
417    /// Defaults to empty (no detection-based anchoring).
418    #[serde(default, skip_serializing_if = "Vec::is_empty")]
419    pub anchors: Vec<ResolvedAnchor>,
420
421    /// Maximum distance (meters) used to resolve coordinate-only punctual
422    /// detections to their nearest netelement (Feature 004, FR-009).
423    /// Detections farther than this distance are discarded with
424    /// `DiscardReason::OutOfReach`.
425    #[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    /// Create a new PathConfig with validation
435    #[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    /// Validate configuration parameters
472    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    /// Create a builder for PathConfig
545    pub fn builder() -> PathConfigBuilder {
546        PathConfigBuilder::default()
547    }
548}
549
550impl Default for PathConfig {
551    /// Create default configuration with documented parameter values
552    ///
553    /// Default values:
554    /// - `distance_scale`: 10.0 meters (exponential decay)
555    /// - `heading_scale`: 2.0 degrees (exponential decay)
556    /// - `cutoff_distance`: 500.0 meters
557    /// - `heading_cutoff`: 10.0 degrees
558    /// - `probability_threshold`: 0.02 (2%)
559    /// - `resampling_distance`: None (disabled)
560    /// - `max_candidates`: 3 netelements per position
561    /// - `path_only`: false (calculate path and project positions)
562    /// - `debug_mode`: false (no debug output)
563    /// - `beta`: 50.0 meters (transition probability scale)
564    /// - `edge_zone_distance`: 50.0 meters (edge-zone optimization threshold)
565    /// - `turn_scale`: 30.0 degrees (turn-angle penalty scale)
566    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/// Builder for PathConfig with fluent API and validation
587///
588/// # Examples
589///
590/// ```
591/// use tp_lib_core::PathConfig;
592///
593/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
594/// let config = PathConfig::builder()
595///     .distance_scale(15.0)
596///     .heading_scale(3.0)
597///     .cutoff_distance(500.0)
598///     .heading_cutoff(10.0)
599///     .probability_threshold(0.3)
600///     .resampling_distance(Some(10.0))
601///     .max_candidates(5)
602///     .build()?;
603/// # Ok(())
604/// # }
605/// ```
606#[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    /// Set distance scale parameter
648    pub fn distance_scale(mut self, value: f64) -> Self {
649        self.distance_scale = value;
650        self
651    }
652
653    /// Set heading scale parameter
654    pub fn heading_scale(mut self, value: f64) -> Self {
655        self.heading_scale = value;
656        self
657    }
658
659    /// Set cutoff distance
660    pub fn cutoff_distance(mut self, value: f64) -> Self {
661        self.cutoff_distance = value;
662        self
663    }
664
665    /// Set heading cutoff
666    pub fn heading_cutoff(mut self, value: f64) -> Self {
667        self.heading_cutoff = value;
668        self
669    }
670
671    /// Set probability threshold
672    pub fn probability_threshold(mut self, value: f64) -> Self {
673        self.probability_threshold = value;
674        self
675    }
676
677    /// Set resampling distance
678    pub fn resampling_distance(mut self, value: Option<f64>) -> Self {
679        self.resampling_distance = value;
680        self
681    }
682
683    /// Set maximum candidates
684    pub fn max_candidates(mut self, value: usize) -> Self {
685        self.max_candidates = value;
686        self
687    }
688
689    /// Set path_only mode (calculate path without projecting positions)
690    pub fn path_only(mut self, value: bool) -> Self {
691        self.path_only = value;
692        self
693    }
694
695    /// Set debug mode (collect debug information during path calculation)
696    pub fn debug_mode(mut self, value: bool) -> Self {
697        self.debug_mode = value;
698        self
699    }
700
701    /// Set transition probability scale (β, meters)
702    pub fn beta(mut self, value: f64) -> Self {
703        self.beta = value;
704        self
705    }
706
707    /// Set edge-zone distance threshold (meters)
708    pub fn edge_zone_distance(mut self, value: f64) -> Self {
709        self.edge_zone_distance = value;
710        self
711    }
712
713    /// Set turn-angle scale parameter (degrees)
714    pub fn turn_scale(mut self, value: f64) -> Self {
715        self.turn_scale = value;
716        self
717    }
718
719    /// Set detection anchors injected into the Viterbi path (Feature 004).
720    pub fn anchors(mut self, value: Vec<ResolvedAnchor>) -> Self {
721        self.anchors = value;
722        self
723    }
724
725    /// Set detection cutoff distance (meters) for coordinate-only resolution.
726    pub fn detection_cutoff_distance(mut self, value: f64) -> Self {
727        self.detection_cutoff_distance = value;
728        self
729    }
730
731    /// Build and validate the PathConfig
732    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
764// Re-exports
765pub 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
782// Re-export configuration types
783pub use PathCalculationMode::{FallbackIndependent, TopologyBased};
784/// Calculate the most probable continuous train path through the network
785///
786/// Given GNSS positions, network netelements, netrelations defining connections,
787/// and configuration parameters, calculates the most likely continuous path the train
788/// traversed through the network.
789///
790/// # Arguments
791///
792/// * `gnss_positions` - Ordered sequence of GNSS positions from train journey
793/// * `netelements` - Network segments (track) with LineString geometries
794/// * `netrelations` - Connections between netelements defining navigable paths
795/// * `config` - Path calculation configuration (distance/heading scales, cutoff distances, etc.)
796///
797/// # Returns
798///
799/// `Ok(PathResult)` containing:
800/// - `path`: The calculated train path as TrainPath (if calculation succeeded)
801/// - `mode`: Algorithm mode used (TopologyBased or FallbackIndependent)
802/// - `projected_positions`: GNSS positions with projected coordinates
803/// - `warnings`: Any non-fatal issues encountered during calculation
804///
805/// `Err(ProjectionError)` if:
806/// - No valid path exists through the network
807/// - Input data is invalid
808/// - Calculation fails for other reasons
809///
810/// # Algorithm
811///
812/// 1. **Candidate Selection**: Find candidate netelements within cutoff_distance for each GNSS position
813/// 2. **Emission Probability**: Calculate per-candidate probability using distance and heading alignment
814/// 3. **Viterbi Decoding**: Decode the globally optimal netelement sequence using log-space Viterbi
815/// 4. **Bridge Insertion**: Insert intermediate netelements between non-adjacent Viterbi states
816/// 5. **Path Assembly**: Return the assembled TrainPath with overall probability score
817///
818/// # Configuration Impact
819///
820/// - `distance_scale`: Decay rate for distance probability (default 10.0m)
821/// - `heading_scale`: Decay rate for heading probability (default 2.0°)
822/// - `cutoff_distance`: Maximum distance for candidate selection (default 500.0m)
823/// - `heading_cutoff`: Maximum heading difference, rejects if exceeded (default 10.0°)
824/// - `probability_threshold`: Minimum probability for segment inclusion (default 0.02)
825/// - `max_candidates`: Maximum candidates to evaluate per GNSS position
826///
827/// # Example
828///
829/// ```no_run
830/// use tp_lib_core::{calculate_train_path, PathConfig};
831/// use tp_lib_core::models::{GnssPosition, Netelement, NetRelation};
832/// use geo::LineString;
833/// use chrono::Utc;
834///
835/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
836/// let gnss_positions = vec![
837///     GnssPosition::new(50.8503, 4.3517, Utc::now().into(), "EPSG:4326".to_string())?,
838/// ];
839///
840/// let netelements = vec![
841///     Netelement::new(
842///         "NE_001".to_string(),
843///         LineString::from(vec![(4.3500, 50.8500), (4.3530, 50.8530)]),
844///         "EPSG:4326".to_string(),
845///     )?,
846/// ];
847///
848/// let netrelations = vec![];
849/// let config = PathConfig::default();
850///
851/// let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config)?;
852/// println!("Path: {:?}", result.path);
853/// # Ok(())
854/// # }
855/// ```
856pub 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    // Basic input validation
870    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    // T157: Initialize debug info collector if debug mode is enabled
880    let mut debug_info = if config.debug_mode {
881        Some(DebugInfo::new())
882    } else {
883        None
884    };
885
886    // US5 T129-T130: Apply resampling if configured
887    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        // No resampling - use all positions
896        (gnss_positions.iter().collect(), false, None)
897    };
898
899    // T098/T108: When path_only is true, skip projection phase
900    if config.path_only {
901        // Log that path-only mode is enabled (projection will be skipped after path calculation)
902        // Continue with path calculation but don't project positions
903        // Fall through to the implementation below
904    }
905
906    // Phase 1: Candidate Selection (T044-T049)
907    // For each GNSS position (potentially resampled), find candidate netelements within cutoff distance
908    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    // Phase 2: GNSS-Level Probability (T050-T057)
921    // Calculate probability for each GNSS position-netelement pair
922    // Build index mapping: netelement_id -> index in netelements array
923    let netelement_index: HashMap<String, usize> = netelements
924        .iter()
925        .enumerate()
926        .map(|(idx, ne)| (ne.id.clone(), idx))
927        .collect();
928
929    // Pre-compute estimated headings from neighboring positions for the
930    // working set.  These are used as fallback when no heading data is supplied.
931    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(); // Vec<HashMap<netelement_idx, probability>>
935
936    for (pos_idx, candidates) in position_candidates.iter().enumerate() {
937        let mut probs = HashMap::new();
938        let gnss = working_positions[pos_idx]; // Use working_positions (potentially resampled)
939
940        // T157: Collect debug info for position candidates
941        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            // Distance probability
955            let dist_prob =
956                calculate_distance_probability(candidate.distance_meters, config.distance_scale);
957
958            // Heading probability: prefer supplied heading, fall back to
959            // estimated heading from neighbors, default to 1.0 (no constraint).
960            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 // No heading data, assume heading match
980            };
981
982            // Combined probability
983            let combined = calculate_combined_probability(dist_prob, heading_prob);
984            probs.insert(*netelement_idx, combined);
985
986            // T157: Collect candidate info for debug output
987            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        // T157: Add position candidates to debug info
1011        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    // Phase 3-5 (HMM): Viterbi decoding replaces netelement aggregation,
1030    // greedy construction, and bidirectional selection.
1031
1032    // Build per-candidate emission probabilities for the Viterbi algorithm.
1033    // position_probabilities is Vec<HashMap<netelement_idx, f64>> (keyed by NE index).
1034    // Viterbi expects Vec<Vec<f64>> indexed by candidate within position_candidates[t].
1035    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    // Detection anchor injection (T019): override candidates / emissions at
1053    // every anchored GNSS step so the Viterbi pass is forced through the
1054    // anchored netelement(s).
1055    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    // Build topology graph with distance-weighted edges.
1070    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    // Run Viterbi decoding.
1075    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    // Build path segments from Viterbi output (with bridge insertion).
1087    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    // Post-Viterbi navigability sanity check: remove unreachable segments
1098    // and attempt Dijkstra re-routing where possible.
1099    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    // Store sanity decisions in debug info if enabled.
1110    if let Some(ref mut debug) = debug_info {
1111        debug.sanity_decisions = sanity_decisions;
1112    }
1113
1114    // Post-sanity gap filling: re-insert bridge netelements where consecutive
1115    // segments are no longer directly connected after sanity removals.
1116    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    // Store gap-fill records in debug info if enabled.
1125    if let Some(ref mut debug) = debug_info {
1126        debug.gap_fills = gap_fills;
1127    }
1128
1129    // Compute overall path probability from Viterbi log-probability.
1130    let path_probability = if viterbi_result.subsequences.is_empty() {
1131        0.0
1132    } else {
1133        // Use the average log-probability per state, exponentiated, clamped to [0, 1].
1134        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    // T157: Populate Viterbi debug info
1158    if let Some(ref mut debug) = debug_info {
1159        // Collect the set of netelement IDs directly selected by Viterbi states
1160        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        // Collect all NE IDs in the final path; bridge NEs are those in the
1169        // path but not directly selected by any Viterbi state.
1170        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        // Build netelement probability info for all candidate netelements
1179        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        // Also add bridge-only NEs (in final path but never a candidate)
1212        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        // Add candidate paths for each Viterbi subsequence
1234        for (sub_idx, subseq) in viterbi_result.subsequences.iter().enumerate() {
1235            // Deduplicate consecutive netelement IDs from the Viterbi states
1236            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        // Add Viterbi decision trace
1258        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        // Add transition probabilities
1289        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    // Create TrainPath if path was found
1311    let train_path = if let Some((segments, prob)) = final_path {
1312        use chrono::Utc;
1313        // Use original gnss_positions for timestamp (not working_positions which might be references)
1314        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, // No metadata for now
1319        )?)
1320    } else {
1321        None
1322    };
1323
1324    // Generate warnings if path calculation had issues
1325    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    // US6 T140-T145: Fallback to independent projection when path calculation fails
1340    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        // T146: Log fallback trigger event
1345        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        // T143: Set mode to FallbackIndependent
1353        let fallback_positions = if config.path_only {
1354            // In path-only mode, return empty projected positions
1355            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            // T141-T142: Use existing simple projection logic from feature 001
1361            // Project each GNSS position to nearest netelement independently, ignoring topology/navigability
1362            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                // T145: Fallback ignores navigability - projects to geometrically nearest
1368                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                    // Calculate projection distance
1383                    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        // T157: Include debug info in fallback result
1401        let mut result = PathResult::new(
1402            None, // No path calculated
1403            PathCalculationMode::FallbackIndependent,
1404            fallback_positions,
1405            warnings,
1406        );
1407        result.debug_info = debug_info;
1408        return Ok(result);
1409    }
1410
1411    // T157: Include debug info in successful result
1412    let mut result = PathResult::new(
1413        train_path,
1414        PathCalculationMode::TopologyBased,
1415        vec![], // No projected positions yet
1416        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
1451/// Project GNSS coordinates onto a calculated train path (US2: T093-T097)
1452///
1453/// Projects each GNSS position onto the nearest segment in the provided path,
1454/// calculating intrinsic coordinates (0-1 range) for each position.
1455///
1456/// # Arguments
1457///
1458/// * `gnss_positions` - Vector of GNSS positions to project
1459/// * `path` - Pre-calculated train path (from calculate_train_path or loaded from file)
1460/// * `netelements` - Railway network elements (needed for geometry)
1461/// * `config` - Path configuration (not currently used, reserved for future)
1462///
1463/// # Returns
1464///
1465/// Vector of ProjectedPosition structs, one per GNSS coordinate
1466///
1467/// # Errors
1468///
1469/// Returns `ProjectionError` if:
1470/// - GNSS positions or path is empty
1471/// - Netelement IDs in path don't exist in netelements collection
1472/// - Projection onto linestring fails
1473/// - Intrinsic coordinates fall outside valid range [0, 1]
1474///
1475/// # Example
1476///
1477/// ```no_run
1478/// use tp_lib_core::{project_onto_path, PathConfig};
1479/// use tp_lib_core::models::{GnssPosition, TrainPath, Netelement};
1480///
1481/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1482/// // Load pre-calculated path
1483/// let path: TrainPath = todo!(); // From file or calculate_train_path
1484/// let netelements: Vec<Netelement> = todo!();
1485/// let gnss_positions: Vec<GnssPosition> = todo!();
1486///
1487/// let config = PathConfig::default();
1488/// let projected = project_onto_path(&gnss_positions, &path, &netelements, &config)?;
1489///
1490/// // Each projected position has netelement_id and intrinsic coordinate
1491/// for proj in projected {
1492///     if let Some(intrinsic) = proj.intrinsic {
1493///         println!("Projected to {} at intrinsic {:.3}", proj.netelement_id, intrinsic);
1494///     }
1495/// }
1496/// # Ok(())
1497/// # }
1498/// ```
1499pub 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    // Validate inputs (T100)
1512    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    // Build netelement lookup map (T094)
1525    let netelement_map: HashMap<_, _> = netelements.iter().map(|ne| (ne.id.as_str(), ne)).collect();
1526
1527    // Validate all path segments exist in netelements
1528    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    // Project each GNSS position onto the path
1542    for gnss in gnss_positions {
1543        // Find closest segment in path (T094)
1544        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            // Project point onto this segment
1552            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        // Project onto best segment (T095, T096)
1566        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        // Calculate intrinsic coordinate (0-1 range) (T096)
1573        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        // Validate intrinsic coordinate (T100)
1584        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        // Create ProjectedPosition with intrinsic coordinate (T096)
1594        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}