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::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
80impl PathResult {
81    /// Create a new path result
82    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    /// Create a new path result with debug info
98    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    /// Check if debug info is available
115    pub fn has_debug_info(&self) -> bool {
116        self.debug_info.is_some()
117    }
118
119    /// Check if topology-based calculation was used
120    pub fn is_topology_based(&self) -> bool {
121        self.mode == PathCalculationMode::TopologyBased
122    }
123
124    /// Check if fallback mode was used
125    pub fn is_fallback(&self) -> bool {
126        self.mode == PathCalculationMode::FallbackIndependent
127    }
128
129    /// Check if path calculation succeeded
130    pub fn has_path(&self) -> bool {
131        self.path.is_some()
132    }
133}
134
135/// Debug information for path calculation (US7: T153)
136///
137/// Contains intermediate results for troubleshooting and parameter tuning.
138/// Collected when `PathConfig::debug_mode` is enabled.
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct DebugInfo {
141    /// Viterbi path subsequences evaluated during path calculation
142    pub candidate_paths: Vec<CandidatePath>,
143
144    /// Candidates considered for each GNSS position
145    pub position_candidates: Vec<PositionCandidates>,
146
147    /// Viterbi decision trace showing state selection per timestep
148    pub decision_tree: Vec<PathDecision>,
149
150    /// Netelement probability information (emission probs + Viterbi membership)
151    pub netelement_probabilities: Vec<NetelementProbabilityInfo>,
152
153    /// Transition probabilities between consecutive candidates (HMM state transitions)
154    pub transition_probabilities: Vec<TransitionProbabilityEntry>,
155
156    /// Sanity check decisions for each consecutive segment pair (post-Viterbi validation)
157    pub sanity_decisions: Vec<viterbi::SanityDecision>,
158
159    /// Gap-fill records for each consecutive segment pair after sanity validation
160    pub gap_fills: Vec<viterbi::GapFill>,
161}
162
163/// Probability information for a single netelement in the Viterbi result
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct NetelementProbabilityInfo {
166    /// Netelement ID
167    pub netelement_id: String,
168
169    /// Average emission probability across positions that matched this netelement
170    pub avg_emission_probability: f64,
171
172    /// Number of GNSS positions where this netelement was a candidate
173    pub position_count: usize,
174
175    /// Geometry coordinates as Vec of [lon, lat] pairs
176    pub geometry_coords: Vec<Vec<f64>>,
177
178    /// Whether this netelement appears in the final Viterbi path
179    pub in_viterbi_path: bool,
180
181    /// Whether this netelement is a bridge (inserted during post-processing)
182    pub is_bridge: bool,
183}
184
185/// Information about a candidate path evaluated during calculation
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct CandidatePath {
188    /// Unique identifier for this candidate path (e.g., "viterbi_0")
189    pub id: String,
190
191    /// Source of this path (e.g., "viterbi")
192    pub direction: String,
193
194    /// Netelement IDs in this candidate path
195    pub segment_ids: Vec<String>,
196
197    /// Per-segment probabilities, parallel to segment_ids
198    pub segment_probabilities: Vec<f64>,
199
200    /// Overall probability score for this path
201    pub probability: f64,
202
203    /// Whether this path was selected as the final result
204    pub selected: bool,
205}
206
207/// Candidates considered for a single GNSS position
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct PositionCandidates {
210    /// Index of the GNSS position (0-based)
211    pub position_index: usize,
212
213    /// Timestamp of the position (ISO 8601)
214    pub timestamp: String,
215
216    /// GNSS coordinates (lat, lon)
217    pub coordinates: (f64, f64),
218
219    /// Candidate netelements with their probabilities
220    pub candidates: Vec<CandidateInfo>,
221
222    /// ID of the netelement selected for this position
223    pub selected_netelement: Option<String>,
224}
225
226/// Information about a single candidate netelement
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct CandidateInfo {
229    /// Netelement ID
230    pub netelement_id: String,
231
232    /// Distance from GNSS position to netelement (meters)
233    pub distance: f64,
234
235    /// Heading difference (degrees)
236    pub heading_difference: Option<f64>,
237
238    /// Distance-based probability component
239    pub distance_probability: f64,
240
241    /// Heading-based probability component
242    pub heading_probability: Option<f64>,
243
244    /// Combined probability
245    pub combined_probability: f64,
246
247    /// Why this candidate was included or excluded
248    pub status: String,
249
250    /// Projected point latitude (WGS84)
251    pub projected_lat: f64,
252
253    /// Projected point longitude (WGS84)
254    pub projected_lon: f64,
255}
256
257/// A single transition probability between two candidates at consecutive GNSS positions
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct TransitionProbabilityEntry {
260    /// Position index of the preceding observation
261    pub from_step: usize,
262    /// Position index of the current observation
263    pub to_step: usize,
264    /// Netelement of the preceding candidate
265    pub from_netelement_id: String,
266    /// Netelement of the current candidate
267    pub to_netelement_id: String,
268    /// Linear-scale transition probability \[0, 1\]
269    pub transition_probability: f64,
270    /// True if this (from, to) pair was chosen by the Viterbi algorithm
271    pub is_viterbi_chosen: bool,
272}
273
274/// A decision point in the Viterbi path decoding process
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct PathDecision {
277    /// Step number (position index in the GNSS sequence)
278    pub step: usize,
279
280    /// Type of decision (e.g., "viterbi_transition", "viterbi_break", "viterbi_init")
281    pub decision_type: String,
282
283    /// Netelement chosen at this step
284    pub current_segment: String,
285
286    /// Candidate netelements considered at this step
287    pub options: Vec<String>,
288
289    /// Log-probabilities for each candidate at this step
290    pub option_probabilities: Vec<f64>,
291
292    /// Which netelement was chosen (best Viterbi state)
293    pub chosen_option: String,
294
295    /// Reason for the choice
296    pub reason: String,
297}
298
299impl DebugInfo {
300    /// Create a new empty DebugInfo
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Add a candidate path to the debug info
306    pub fn add_candidate_path(&mut self, path: CandidatePath) {
307        self.candidate_paths.push(path);
308    }
309
310    /// Add position candidates info
311    pub fn add_position_candidates(&mut self, candidates: PositionCandidates) {
312        self.position_candidates.push(candidates);
313    }
314
315    /// Add a decision to the tree
316    pub fn add_decision(&mut self, decision: PathDecision) {
317        self.decision_tree.push(decision);
318    }
319
320    /// Export debug info to JSON string
321    pub fn to_json(&self) -> Result<String, serde_json::Error> {
322        serde_json::to_string_pretty(self)
323    }
324
325    /// Check if any debug information was collected
326    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/// Configuration for train path calculation algorithm
335///
336/// Controls probability thresholds, distance cutoffs, and other parameters
337/// that affect path selection and candidate filtering.
338///
339/// # Examples
340///
341/// ```
342/// use tp_lib_core::PathConfig;
343///
344/// // Use default configuration
345/// let config = PathConfig::default();
346/// assert_eq!(config.distance_scale, 10.0);
347///
348/// // Create custom configuration
349/// let config = PathConfig::builder()
350///     .distance_scale(15.0)
351///     .heading_scale(3.0)
352///     .cutoff_distance(75.0)
353///     .build()
354///     .unwrap();
355/// ```
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct PathConfig {
358    /// Distance scale parameter for exponential decay (meters)
359    /// Controls how quickly probability decreases with distance
360    pub distance_scale: f64,
361
362    /// Heading scale parameter for exponential decay (degrees)
363    /// Controls how quickly probability decreases with heading difference
364    pub heading_scale: f64,
365
366    /// Maximum distance from GNSS position to consider netelement as candidate (meters)
367    pub cutoff_distance: f64,
368
369    /// Maximum heading difference to consider netelement as candidate (degrees)
370    /// Positions with larger heading differences are filtered out
371    pub heading_cutoff: f64,
372
373    /// Minimum probability threshold for including segment in path (0.0 to 1.0)
374    pub probability_threshold: f64,
375
376    /// Distance between resampled positions (meters), None to disable resampling
377    pub resampling_distance: Option<f64>,
378
379    /// Maximum number of candidate netelements per GNSS position
380    pub max_candidates: usize,
381
382    /// If true, only calculate path without projecting positions (US2: T098)
383    /// When true, PathResult.projected_positions will be empty
384    pub path_only: bool,
385
386    /// If true, collect and return debug information about path calculation (US7: T152)
387    /// Includes candidate paths, position candidates, and decision tree
388    pub debug_mode: bool,
389
390    /// Transition probability scale parameter (meters, Newson & Krumm β)
391    /// Controls tolerance for mismatch between route distance and great-circle distance.
392    /// Higher values are more forgiving of detours.
393    pub beta: f64,
394
395    /// Edge-zone distance threshold (meters)
396    /// Candidates whose projected point is farther than this from the nearest
397    /// netelement endpoint are considered interior and cannot transition to a
398    /// different netelement (transition probability = 0).
399    pub edge_zone_distance: f64,
400
401    /// Turn-angle scale parameter (degrees)
402    /// Penalises transitions that require a direction change at a netelement connection.
403    /// The penalty factor is exp(-turn_angle / turn_scale).
404    /// Lower values penalise turns more aggressively.
405    pub turn_scale: f64,
406}
407
408impl PathConfig {
409    /// Create a new PathConfig with validation
410    #[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    /// Validate configuration parameters
445    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    /// Create a builder for PathConfig
512    pub fn builder() -> PathConfigBuilder {
513        PathConfigBuilder::default()
514    }
515}
516
517impl Default for PathConfig {
518    /// Create default configuration with documented parameter values
519    ///
520    /// Default values:
521    /// - `distance_scale`: 10.0 meters (exponential decay)
522    /// - `heading_scale`: 2.0 degrees (exponential decay)
523    /// - `cutoff_distance`: 500.0 meters
524    /// - `heading_cutoff`: 10.0 degrees
525    /// - `probability_threshold`: 0.02 (2%)
526    /// - `resampling_distance`: None (disabled)
527    /// - `max_candidates`: 3 netelements per position
528    /// - `path_only`: false (calculate path and project positions)
529    /// - `debug_mode`: false (no debug output)
530    /// - `beta`: 50.0 meters (transition probability scale)
531    /// - `edge_zone_distance`: 50.0 meters (edge-zone optimization threshold)
532    /// - `turn_scale`: 30.0 degrees (turn-angle penalty scale)
533    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/// Builder for PathConfig with fluent API and validation
552///
553/// # Examples
554///
555/// ```
556/// use tp_lib_core::PathConfig;
557///
558/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
559/// let config = PathConfig::builder()
560///     .distance_scale(15.0)
561///     .heading_scale(3.0)
562///     .cutoff_distance(500.0)
563///     .heading_cutoff(10.0)
564///     .probability_threshold(0.3)
565///     .resampling_distance(Some(10.0))
566///     .max_candidates(5)
567///     .build()?;
568/// # Ok(())
569/// # }
570/// ```
571#[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    /// Set distance scale parameter
609    pub fn distance_scale(mut self, value: f64) -> Self {
610        self.distance_scale = value;
611        self
612    }
613
614    /// Set heading scale parameter
615    pub fn heading_scale(mut self, value: f64) -> Self {
616        self.heading_scale = value;
617        self
618    }
619
620    /// Set cutoff distance
621    pub fn cutoff_distance(mut self, value: f64) -> Self {
622        self.cutoff_distance = value;
623        self
624    }
625
626    /// Set heading cutoff
627    pub fn heading_cutoff(mut self, value: f64) -> Self {
628        self.heading_cutoff = value;
629        self
630    }
631
632    /// Set probability threshold
633    pub fn probability_threshold(mut self, value: f64) -> Self {
634        self.probability_threshold = value;
635        self
636    }
637
638    /// Set resampling distance
639    pub fn resampling_distance(mut self, value: Option<f64>) -> Self {
640        self.resampling_distance = value;
641        self
642    }
643
644    /// Set maximum candidates
645    pub fn max_candidates(mut self, value: usize) -> Self {
646        self.max_candidates = value;
647        self
648    }
649
650    /// Set path_only mode (calculate path without projecting positions)
651    pub fn path_only(mut self, value: bool) -> Self {
652        self.path_only = value;
653        self
654    }
655
656    /// Set debug mode (collect debug information during path calculation)
657    pub fn debug_mode(mut self, value: bool) -> Self {
658        self.debug_mode = value;
659        self
660    }
661
662    /// Set transition probability scale (β, meters)
663    pub fn beta(mut self, value: f64) -> Self {
664        self.beta = value;
665        self
666    }
667
668    /// Set edge-zone distance threshold (meters)
669    pub fn edge_zone_distance(mut self, value: f64) -> Self {
670        self.edge_zone_distance = value;
671        self
672    }
673
674    /// Set turn-angle scale parameter (degrees)
675    pub fn turn_scale(mut self, value: f64) -> Self {
676        self.turn_scale = value;
677        self
678    }
679
680    /// Build and validate the PathConfig
681    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
709// Re-exports
710pub 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
727// Re-export configuration types
728pub use PathCalculationMode::{FallbackIndependent, TopologyBased};
729/// Calculate the most probable continuous train path through the network
730///
731/// Given GNSS positions, network netelements, netrelations defining connections,
732/// and configuration parameters, calculates the most likely continuous path the train
733/// traversed through the network.
734///
735/// # Arguments
736///
737/// * `gnss_positions` - Ordered sequence of GNSS positions from train journey
738/// * `netelements` - Network segments (track) with LineString geometries
739/// * `netrelations` - Connections between netelements defining navigable paths
740/// * `config` - Path calculation configuration (distance/heading scales, cutoff distances, etc.)
741///
742/// # Returns
743///
744/// `Ok(PathResult)` containing:
745/// - `path`: The calculated train path as TrainPath (if calculation succeeded)
746/// - `mode`: Algorithm mode used (TopologyBased or FallbackIndependent)
747/// - `projected_positions`: GNSS positions with projected coordinates
748/// - `warnings`: Any non-fatal issues encountered during calculation
749///
750/// `Err(ProjectionError)` if:
751/// - No valid path exists through the network
752/// - Input data is invalid
753/// - Calculation fails for other reasons
754///
755/// # Algorithm
756///
757/// 1. **Candidate Selection**: Find candidate netelements within cutoff_distance for each GNSS position
758/// 2. **Emission Probability**: Calculate per-candidate probability using distance and heading alignment
759/// 3. **Viterbi Decoding**: Decode the globally optimal netelement sequence using log-space Viterbi
760/// 4. **Bridge Insertion**: Insert intermediate netelements between non-adjacent Viterbi states
761/// 5. **Path Assembly**: Return the assembled TrainPath with overall probability score
762///
763/// # Configuration Impact
764///
765/// - `distance_scale`: Decay rate for distance probability (default 10.0m)
766/// - `heading_scale`: Decay rate for heading probability (default 2.0°)
767/// - `cutoff_distance`: Maximum distance for candidate selection (default 500.0m)
768/// - `heading_cutoff`: Maximum heading difference, rejects if exceeded (default 10.0°)
769/// - `probability_threshold`: Minimum probability for segment inclusion (default 0.02)
770/// - `max_candidates`: Maximum candidates to evaluate per GNSS position
771///
772/// # Example
773///
774/// ```no_run
775/// use tp_lib_core::{calculate_train_path, PathConfig};
776/// use tp_lib_core::models::{GnssPosition, Netelement, NetRelation};
777/// use geo::LineString;
778/// use chrono::Utc;
779///
780/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
781/// let gnss_positions = vec![
782///     GnssPosition::new(50.8503, 4.3517, Utc::now().into(), "EPSG:4326".to_string())?,
783/// ];
784///
785/// let netelements = vec![
786///     Netelement::new(
787///         "NE_001".to_string(),
788///         LineString::from(vec![(4.3500, 50.8500), (4.3530, 50.8530)]),
789///         "EPSG:4326".to_string(),
790///     )?,
791/// ];
792///
793/// let netrelations = vec![];
794/// let config = PathConfig::default();
795///
796/// let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config)?;
797/// println!("Path: {:?}", result.path);
798/// # Ok(())
799/// # }
800/// ```
801pub 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    // Basic input validation
815    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    // T157: Initialize debug info collector if debug mode is enabled
825    let mut debug_info = if config.debug_mode {
826        Some(DebugInfo::new())
827    } else {
828        None
829    };
830
831    // US5 T129-T130: Apply resampling if configured
832    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        // No resampling - use all positions
840        (gnss_positions.iter().collect(), false)
841    };
842
843    // T098/T108: When path_only is true, skip projection phase
844    if config.path_only {
845        // Log that path-only mode is enabled (projection will be skipped after path calculation)
846        // Continue with path calculation but don't project positions
847        // Fall through to the implementation below
848    }
849
850    // Phase 1: Candidate Selection (T044-T049)
851    // For each GNSS position (potentially resampled), find candidate netelements within cutoff distance
852    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    // Phase 2: GNSS-Level Probability (T050-T057)
865    // Calculate probability for each GNSS position-netelement pair
866    // Build index mapping: netelement_id -> index in netelements array
867    let netelement_index: HashMap<String, usize> = netelements
868        .iter()
869        .enumerate()
870        .map(|(idx, ne)| (ne.id.clone(), idx))
871        .collect();
872
873    // Pre-compute estimated headings from neighboring positions for the
874    // working set.  These are used as fallback when no heading data is supplied.
875    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(); // Vec<HashMap<netelement_idx, probability>>
879
880    for (pos_idx, candidates) in position_candidates.iter().enumerate() {
881        let mut probs = HashMap::new();
882        let gnss = working_positions[pos_idx]; // Use working_positions (potentially resampled)
883
884        // T157: Collect debug info for position candidates
885        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            // Distance probability
899            let dist_prob =
900                calculate_distance_probability(candidate.distance_meters, config.distance_scale);
901
902            // Heading probability: prefer supplied heading, fall back to
903            // estimated heading from neighbors, default to 1.0 (no constraint).
904            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 // No heading data, assume heading match
924            };
925
926            // Combined probability
927            let combined = calculate_combined_probability(dist_prob, heading_prob);
928            probs.insert(*netelement_idx, combined);
929
930            // T157: Collect candidate info for debug output
931            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        // T157: Add position candidates to debug info
955        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    // Phase 3-5 (HMM): Viterbi decoding replaces netelement aggregation,
974    // greedy construction, and bidirectional selection.
975
976    // Build per-candidate emission probabilities for the Viterbi algorithm.
977    // position_probabilities is Vec<HashMap<netelement_idx, f64>> (keyed by NE index).
978    // Viterbi expects Vec<Vec<f64>> indexed by candidate within position_candidates[t].
979    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    // Build topology graph with distance-weighted edges.
997    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    // Run Viterbi decoding.
1002    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    // Build path segments from Viterbi output (with bridge insertion).
1014    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    // Post-Viterbi navigability sanity check: remove unreachable segments
1025    // and attempt Dijkstra re-routing where possible.
1026    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    // Store sanity decisions in debug info if enabled.
1037    if let Some(ref mut debug) = debug_info {
1038        debug.sanity_decisions = sanity_decisions;
1039    }
1040
1041    // Post-sanity gap filling: re-insert bridge netelements where consecutive
1042    // segments are no longer directly connected after sanity removals.
1043    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    // Store gap-fill records in debug info if enabled.
1052    if let Some(ref mut debug) = debug_info {
1053        debug.gap_fills = gap_fills;
1054    }
1055
1056    // Compute overall path probability from Viterbi log-probability.
1057    let path_probability = if viterbi_result.subsequences.is_empty() {
1058        0.0
1059    } else {
1060        // Use the average log-probability per state, exponentiated, clamped to [0, 1].
1061        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    // T157: Populate Viterbi debug info
1085    if let Some(ref mut debug) = debug_info {
1086        // Collect the set of netelement IDs directly selected by Viterbi states
1087        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        // Collect all NE IDs in the final path; bridge NEs are those in the
1096        // path but not directly selected by any Viterbi state.
1097        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        // Build netelement probability info for all candidate netelements
1106        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        // Also add bridge-only NEs (in final path but never a candidate)
1139        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        // Add candidate paths for each Viterbi subsequence
1161        for (sub_idx, subseq) in viterbi_result.subsequences.iter().enumerate() {
1162            // Deduplicate consecutive netelement IDs from the Viterbi states
1163            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        // Add Viterbi decision trace
1185        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        // Add transition probabilities
1216        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    // Create TrainPath if path was found
1238    let train_path = if let Some((segments, prob)) = final_path {
1239        use chrono::Utc;
1240        // Use original gnss_positions for timestamp (not working_positions which might be references)
1241        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, // No metadata for now
1246        )?)
1247    } else {
1248        None
1249    };
1250
1251    // Generate warnings if path calculation had issues
1252    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    // US6 T140-T145: Fallback to independent projection when path calculation fails
1267    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        // T146: Log fallback trigger event
1272        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        // T143: Set mode to FallbackIndependent
1280        let fallback_positions = if config.path_only {
1281            // In path-only mode, return empty projected positions
1282            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            // T141-T142: Use existing simple projection logic from feature 001
1288            // Project each GNSS position to nearest netelement independently, ignoring topology/navigability
1289            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                // T145: Fallback ignores navigability - projects to geometrically nearest
1295                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                    // Calculate projection distance
1310                    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        // T157: Include debug info in fallback result
1328        let mut result = PathResult::new(
1329            None, // No path calculated
1330            PathCalculationMode::FallbackIndependent,
1331            fallback_positions,
1332            warnings,
1333        );
1334        result.debug_info = debug_info;
1335        return Ok(result);
1336    }
1337
1338    // T157: Include debug info in successful result
1339    let mut result = PathResult::new(
1340        train_path,
1341        PathCalculationMode::TopologyBased,
1342        vec![], // No projected positions yet
1343        warnings,
1344    );
1345    result.debug_info = debug_info;
1346    Ok(result)
1347}
1348
1349/// Project GNSS coordinates onto a calculated train path (US2: T093-T097)
1350///
1351/// Projects each GNSS position onto the nearest segment in the provided path,
1352/// calculating intrinsic coordinates (0-1 range) for each position.
1353///
1354/// # Arguments
1355///
1356/// * `gnss_positions` - Vector of GNSS positions to project
1357/// * `path` - Pre-calculated train path (from calculate_train_path or loaded from file)
1358/// * `netelements` - Railway network elements (needed for geometry)
1359/// * `config` - Path configuration (not currently used, reserved for future)
1360///
1361/// # Returns
1362///
1363/// Vector of ProjectedPosition structs, one per GNSS coordinate
1364///
1365/// # Errors
1366///
1367/// Returns `ProjectionError` if:
1368/// - GNSS positions or path is empty
1369/// - Netelement IDs in path don't exist in netelements collection
1370/// - Projection onto linestring fails
1371/// - Intrinsic coordinates fall outside valid range [0, 1]
1372///
1373/// # Example
1374///
1375/// ```no_run
1376/// use tp_lib_core::{project_onto_path, PathConfig};
1377/// use tp_lib_core::models::{GnssPosition, TrainPath, Netelement};
1378///
1379/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1380/// // Load pre-calculated path
1381/// let path: TrainPath = todo!(); // From file or calculate_train_path
1382/// let netelements: Vec<Netelement> = todo!();
1383/// let gnss_positions: Vec<GnssPosition> = todo!();
1384///
1385/// let config = PathConfig::default();
1386/// let projected = project_onto_path(&gnss_positions, &path, &netelements, &config)?;
1387///
1388/// // Each projected position has netelement_id and intrinsic coordinate
1389/// for proj in projected {
1390///     if let Some(intrinsic) = proj.intrinsic {
1391///         println!("Projected to {} at intrinsic {:.3}", proj.netelement_id, intrinsic);
1392///     }
1393/// }
1394/// # Ok(())
1395/// # }
1396/// ```
1397pub 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    // Validate inputs (T100)
1410    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    // Build netelement lookup map (T094)
1423    let netelement_map: HashMap<_, _> = netelements.iter().map(|ne| (ne.id.as_str(), ne)).collect();
1424
1425    // Validate all path segments exist in netelements
1426    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    // Project each GNSS position onto the path
1440    for gnss in gnss_positions {
1441        // Find closest segment in path (T094)
1442        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            // Project point onto this segment
1450            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        // Project onto best segment (T095, T096)
1464        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        // Calculate intrinsic coordinate (0-1 range) (T096)
1471        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        // Validate intrinsic coordinate (T100)
1482        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        // Create ProjectedPosition with intrinsic coordinate (T096)
1492        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}