Skip to main content

tp_lib_core/io/
geojson.rs

1//! GeoJSON parsing and writing
2
3use crate::errors::ProjectionError;
4use crate::models::{GnssPosition, NetRelation, Netelement, ProjectedPosition};
5use chrono::DateTime;
6use geo::{Coord, LineString};
7use geojson::{Feature, GeoJson, Value};
8use std::fs;
9
10// Default CRS constant for GeoJSON (RFC 7946)
11const DEFAULT_CRS: &str = "EPSG:4326";
12
13/// Parse CRS from GeoJSON FeatureCollection
14///
15/// Extracts CRS from foreign_members or returns default WGS84
16fn parse_crs_from_feature_collection(feature_collection: &geojson::FeatureCollection) -> String {
17    let Some(crs_obj) = &feature_collection.foreign_members else {
18        return DEFAULT_CRS.to_string();
19    };
20
21    let Some(crs_value) = crs_obj.get("crs") else {
22        return DEFAULT_CRS.to_string();
23    };
24
25    // Try to extract CRS from properties.name
26    crs_value
27        .get("properties")
28        .and_then(|props| props.get("name"))
29        .and_then(|name| name.as_str())
30        .and_then(|name_str| {
31            // Handle URN format: "urn:ogc:def:crs:EPSG::4326"
32            if name_str.contains("EPSG") {
33                name_str
34                    .split("::")
35                    .last()
36                    .map(|code| format!("EPSG:{}", code))
37            } else {
38                None
39            }
40        })
41        .unwrap_or_else(|| DEFAULT_CRS.to_string())
42}
43
44/// Parse railway network from GeoJSON file
45///
46/// Loads both netelements and netrelations from a single GeoJSON FeatureCollection.
47/// Netelements are features with LineString/MultiLineString geometry (without type="netrelation").
48/// Netrelations are features with type="netrelation" property.
49///
50/// # Arguments
51///
52/// * `path` - Path to GeoJSON file containing both network elements and relations
53///
54/// # Returns
55///
56/// A tuple containing `(Vec<Netelement>, Vec<NetRelation>)`
57///
58/// # Example
59///
60/// ```no_run
61/// use tp_lib_core::io::parse_network_geojson;
62///
63/// let (netelements, netrelations) = parse_network_geojson("network.geojson")?;
64/// # Ok::<_, Box<dyn std::error::Error>>(())
65/// ```
66pub fn parse_network_geojson(
67    path: &str,
68) -> Result<(Vec<Netelement>, Vec<NetRelation>), ProjectionError> {
69    // Read file
70    let geojson_str = fs::read_to_string(path)?;
71
72    // Parse GeoJSON
73    let geojson = geojson_str
74        .parse::<GeoJson>()
75        .map_err(|e| ProjectionError::InvalidGeometry(format!("Failed to parse GeoJSON: {}", e)))?;
76
77    // Extract FeatureCollection
78    let feature_collection = match geojson {
79        GeoJson::FeatureCollection(fc) => fc,
80        _ => {
81            return Err(ProjectionError::InvalidGeometry(
82                "GeoJSON must be a FeatureCollection".to_string(),
83            ))
84        }
85    };
86
87    // Determine CRS - RFC 7946 specifies WGS84 is the default
88    let crs = parse_crs_from_feature_collection(&feature_collection);
89
90    // Validate CRS is WGS84 per RFC 7946
91    if !crs.contains("4326") && !crs.contains("WGS84") {
92        return Err(ProjectionError::InvalidCrs(format!(
93            "GeoJSON CRS must be WGS84 (EPSG:4326) per RFC 7946, got: {}",
94            crs
95        )));
96    }
97
98    // Parse features, separating netelements and netrelations
99    let mut netelements = Vec::new();
100    let mut netrelations = Vec::new();
101
102    for (idx, feature) in feature_collection.features.iter().enumerate() {
103        // Check if this is a netrelation feature
104        if let Some(props) = &feature.properties {
105            if let Some(feature_type) = props.get("type") {
106                if feature_type.as_str() == Some("netrelation") {
107                    let netrelation = parse_netrelation_feature(feature, idx)?;
108                    netrelations.push(netrelation);
109                    continue;
110                }
111            }
112        }
113
114        // Otherwise parse as netelement
115        let netelement = parse_feature(feature, &crs, idx)?;
116        netelements.push(netelement);
117    }
118
119    if netelements.is_empty() {
120        return Err(ProjectionError::EmptyNetwork);
121    }
122
123    Ok((netelements, netrelations))
124}
125
126/// Parse GNSS positions from GeoJSON file
127///
128/// Expects a FeatureCollection with Point geometries and properties:
129/// - `timestamp`: RFC3339 timestamp with timezone
130/// - Optional: other properties will be stored as metadata
131///
132/// # Arguments
133///
134/// * `path` - Path to GeoJSON file
135/// * `crs` - Expected CRS of the coordinates (e.g., "EPSG:4326")
136///
137/// # Returns
138///
139/// Vector of GnssPosition structs
140///
141/// # Example GeoJSON
142///
143/// ```json
144/// {
145///   "type": "FeatureCollection",
146///   "features": [
147///     {
148///       "type": "Feature",
149///       "geometry": {
150///         "type": "Point",
151///         "coordinates": [4.3517, 50.8503]
152///       },
153///       "properties": {
154///         "timestamp": "2025-12-09T14:30:00+01:00",
155///         "vehicle_id": "TRAIN_001"
156///       }
157///     }
158///   ]
159/// }
160/// ```
161pub fn parse_gnss_geojson(path: &str, crs: &str) -> Result<Vec<GnssPosition>, ProjectionError> {
162    // Read file
163    let geojson_str = fs::read_to_string(path)?;
164
165    // Parse GeoJSON
166    let geojson = geojson_str
167        .parse::<GeoJson>()
168        .map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
169
170    // Extract FeatureCollection
171    let feature_collection = match geojson {
172        GeoJson::FeatureCollection(fc) => fc,
173        _ => {
174            return Err(ProjectionError::GeoJsonError(
175                "GeoJSON must be a FeatureCollection".to_string(),
176            ))
177        }
178    };
179
180    // Parse features
181    let mut positions = Vec::new();
182
183    for (idx, feature) in feature_collection.features.iter().enumerate() {
184        let position = parse_gnss_feature(feature, crs, idx)?;
185        positions.push(position);
186    }
187
188    if positions.is_empty() {
189        return Err(ProjectionError::GeoJsonError(
190            "GeoJSON contains no valid GNSS positions".to_string(),
191        ));
192    }
193
194    Ok(positions)
195}
196
197/// Parse a single GeoJSON feature into a GnssPosition
198fn parse_gnss_feature(
199    feature: &Feature,
200    crs: &str,
201    idx: usize,
202) -> Result<GnssPosition, ProjectionError> {
203    // Get geometry
204    let geometry = feature.geometry.as_ref().ok_or_else(|| {
205        ProjectionError::GeoJsonError(format!("Feature {} missing geometry", idx))
206    })?;
207
208    // Extract Point coordinates (longitude, latitude)
209    let (longitude, latitude) = match &geometry.value {
210        Value::Point(coords) => {
211            if coords.len() < 2 {
212                return Err(ProjectionError::InvalidCoordinate(format!(
213                    "Feature {} Point must have at least 2 coordinates",
214                    idx
215                )));
216            }
217            (coords[0], coords[1])
218        }
219        _ => {
220            return Err(ProjectionError::GeoJsonError(format!(
221                "Feature {} must have Point geometry for GNSS position",
222                idx
223            )))
224        }
225    };
226
227    // Validate coordinates
228    if !(-90.0..=90.0).contains(&latitude) {
229        return Err(ProjectionError::InvalidCoordinate(format!(
230            "Feature {}: latitude {} out of range [-90, 90]",
231            idx, latitude
232        )));
233    }
234    if !(-180.0..=180.0).contains(&longitude) {
235        return Err(ProjectionError::InvalidCoordinate(format!(
236            "Feature {}: longitude {} out of range [-180, 180]",
237            idx, longitude
238        )));
239    }
240
241    // Get properties
242    let properties = feature.properties.as_ref().ok_or_else(|| {
243        ProjectionError::GeoJsonError(format!(
244            "Feature {} missing properties (timestamp required)",
245            idx
246        ))
247    })?;
248
249    // Extract timestamp
250    let timestamp_str = properties
251        .get("timestamp")
252        .and_then(|v| v.as_str())
253        .ok_or_else(|| {
254            ProjectionError::MissingTimezone(format!(
255                "Feature {} missing 'timestamp' property",
256                idx
257            ))
258        })?;
259
260    // Parse timestamp with timezone
261    let timestamp = DateTime::parse_from_rfc3339(timestamp_str).map_err(|e| {
262        ProjectionError::InvalidTimestamp(format!(
263            "Feature {}: invalid timestamp '{}': {}",
264            idx, timestamp_str, e
265        ))
266    })?;
267
268    // Extract metadata (all properties except timestamp, heading, distance)
269    let mut metadata = std::collections::HashMap::new();
270    let mut heading: Option<f64> = None;
271    let mut distance: Option<f64> = None;
272
273    for (key, value) in properties {
274        match key.as_str() {
275            "timestamp" => {} // Skip, already extracted
276            "heading" => {
277                // Extract optional heading (0-360°)
278                if let Some(h) = value.as_f64() {
279                    if (0.0..=360.0).contains(&h) {
280                        heading = Some(h);
281                    } else {
282                        return Err(ProjectionError::InvalidGeometry(format!(
283                            "Feature {}: heading {} not in [0, 360]",
284                            idx, h
285                        )));
286                    }
287                }
288            }
289            "distance" => {
290                // Extract optional distance (>= 0)
291                if let Some(d) = value.as_f64() {
292                    if d >= 0.0 {
293                        distance = Some(d);
294                    } else {
295                        return Err(ProjectionError::InvalidGeometry(format!(
296                            "Feature {}: distance {} must be >= 0",
297                            idx, d
298                        )));
299                    }
300                }
301            }
302            _ => {
303                // Store other properties as metadata
304                if let Some(str_value) = value.as_str() {
305                    metadata.insert(key.clone(), str_value.to_string());
306                } else {
307                    metadata.insert(key.clone(), value.to_string());
308                }
309            }
310        }
311    }
312
313    Ok(GnssPosition {
314        latitude,
315        longitude,
316        timestamp,
317        crs: crs.to_string(),
318        metadata,
319        heading,
320        distance,
321    })
322}
323
324/// Parse a single GeoJSON feature into a Netelement
325fn parse_feature(feature: &Feature, crs: &str, idx: usize) -> Result<Netelement, ProjectionError> {
326    // Get geometry
327    let geometry = feature.geometry.as_ref().ok_or_else(|| {
328        ProjectionError::InvalidGeometry(format!("Feature {} missing geometry", idx))
329    })?;
330
331    // Extract LineString
332    let linestring = match &geometry.value {
333        Value::LineString(coords) => {
334            let geo_coords: Vec<Coord<f64>> = coords
335                .iter()
336                .map(|pos| Coord {
337                    x: pos[0],
338                    y: pos[1],
339                })
340                .collect();
341            LineString::from(geo_coords)
342        }
343        Value::MultiLineString(lines) => {
344            // For MultiLineString, use first line (or could concatenate)
345            if lines.is_empty() {
346                return Err(ProjectionError::InvalidGeometry(format!(
347                    "Feature {} has empty MultiLineString",
348                    idx
349                )));
350            }
351            let geo_coords: Vec<Coord<f64>> = lines[0]
352                .iter()
353                .map(|pos| Coord {
354                    x: pos[0],
355                    y: pos[1],
356                })
357                .collect();
358            LineString::from(geo_coords)
359        }
360        _ => {
361            return Err(ProjectionError::InvalidGeometry(format!(
362                "Feature {} must have LineString or MultiLineString geometry",
363                idx
364            )))
365        }
366    };
367
368    // Get ID from properties (required)
369    let id = if let Some(props) = &feature.properties {
370        if let Some(id_value) = props.get("id") {
371            id_value
372                .as_str()
373                .map(|s| s.to_string())
374                .or_else(|| id_value.as_i64().map(|i| i.to_string()))
375                .ok_or_else(|| {
376                    ProjectionError::GeoJsonError(format!(
377                        "Feature {} has invalid 'id' property type",
378                        idx
379                    ))
380                })?
381        } else {
382            return Err(ProjectionError::GeoJsonError(format!(
383                "Feature {} missing required 'id' property",
384                idx
385            )));
386        }
387    } else {
388        return Err(ProjectionError::GeoJsonError(format!(
389            "Feature {} missing properties object",
390            idx
391        )));
392    };
393
394    Netelement::new(id, linestring, crs.to_string())
395}
396
397/// Parse netrelations from GeoJSON file
398///
399/// Expects a FeatureCollection with features that have `type="netrelation"` property.
400/// Netrelations can have optional Point geometry representing the connection point.
401///
402/// # Required Properties
403///
404/// - `type`: Must be "netrelation"
405/// - `id`: Netrelation identifier
406/// - `netelementA`: ID of first netelement
407/// - `netelementB`: ID of second netelement
408/// - `positionOnA`: Position on netelementA (0 or 1)
409/// - `positionOnB`: Position on netelementB (0 or 1)
410/// - `navigability`: "both", "AB", "BA", or "none"
411///
412/// # Arguments
413///
414/// * `path` - Path to GeoJSON file
415///
416/// # Returns
417///
418/// Vector of NetRelation structs
419///
420/// # Example GeoJSON
421///
422/// ```json
423/// {
424///   "type": "FeatureCollection",
425///   "features": [
426///     {
427///       "type": "Feature",
428///       "geometry": {
429///         "type": "Point",
430///         "coordinates": [4.3517, 50.8503]
431///       },
432///       "properties": {
433///         "type": "netrelation",
434///         "id": "NR_001",
435///         "netelementA": "NE_001",
436///         "netelementB": "NE_002",
437///         "positionOnA": 1,
438///         "positionOnB": 0,
439///         "navigability": "both"
440///       }
441///     }
442///   ]
443/// }
444/// ```
445pub fn parse_netrelations_geojson(path: &str) -> Result<Vec<NetRelation>, ProjectionError> {
446    // Read file
447    let geojson_str = fs::read_to_string(path)?;
448
449    // Parse GeoJSON
450    let geojson = geojson_str
451        .parse::<GeoJson>()
452        .map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
453
454    // Extract FeatureCollection
455    let feature_collection = match geojson {
456        GeoJson::FeatureCollection(fc) => fc,
457        _ => {
458            return Err(ProjectionError::GeoJsonError(
459                "GeoJSON must be a FeatureCollection".to_string(),
460            ))
461        }
462    };
463
464    // Parse features, filtering for type="netrelation"
465    let mut netrelations = Vec::new();
466
467    for (idx, feature) in feature_collection.features.iter().enumerate() {
468        // Check if this is a netrelation feature
469        if let Some(props) = &feature.properties {
470            if let Some(feature_type) = props.get("type") {
471                if feature_type.as_str() == Some("netrelation") {
472                    let netrelation = parse_netrelation_feature(feature, idx)?;
473                    netrelations.push(netrelation);
474                }
475            }
476        }
477    }
478
479    Ok(netrelations)
480}
481
482/// Parse a single GeoJSON feature into a NetRelation
483fn parse_netrelation_feature(
484    feature: &Feature,
485    idx: usize,
486) -> Result<NetRelation, ProjectionError> {
487    // Get properties
488    let properties = feature.properties.as_ref().ok_or_else(|| {
489        ProjectionError::GeoJsonError(format!("Netrelation feature {} missing properties", idx))
490    })?;
491
492    // Extract ID
493    let id = properties
494        .get("id")
495        .and_then(|v| v.as_str())
496        .ok_or_else(|| {
497            ProjectionError::GeoJsonError(format!(
498                "Netrelation feature {} missing 'id' property",
499                idx
500            ))
501        })?
502        .to_string();
503
504    // Extract netelementA (support both 'from' and 'netelementA' for backwards compatibility)
505    let netelement_a = properties
506        .get("netelementA")
507        .or_else(|| properties.get("from"))
508        .and_then(|v| v.as_str())
509        .ok_or_else(|| {
510            ProjectionError::GeoJsonError(format!(
511                "Netrelation feature {} missing 'netelementA' or 'from' property",
512                idx
513            ))
514        })?
515        .to_string();
516
517    // Extract netelementB (support both 'to' and 'netelementB' for backwards compatibility)
518    let netelement_b = properties
519        .get("netelementB")
520        .or_else(|| properties.get("to"))
521        .and_then(|v| v.as_str())
522        .ok_or_else(|| {
523            ProjectionError::GeoJsonError(format!(
524                "Netrelation feature {} missing 'netelementB' or 'to' property",
525                idx
526            ))
527        })?
528        .to_string();
529
530    // Extract positionOnA (0 or 1) - accept both integer and float
531    let position_on_a = properties
532        .get("positionOnA")
533        .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
534        .ok_or_else(|| {
535            ProjectionError::GeoJsonError(format!(
536                "Netrelation feature {} missing or invalid 'positionOnA' property",
537                idx
538            ))
539        })? as u8;
540
541    // Extract positionOnB (0 or 1) - accept both integer and float
542    let position_on_b = properties
543        .get("positionOnB")
544        .and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
545        .ok_or_else(|| {
546            ProjectionError::GeoJsonError(format!(
547                "Netrelation feature {} missing or invalid 'positionOnB' property",
548                idx
549            ))
550        })? as u8;
551
552    // Extract navigability and convert to boolean flags
553    let navigability_str = properties
554        .get("navigability")
555        .and_then(|v| v.as_str())
556        .ok_or_else(|| {
557            ProjectionError::GeoJsonError(format!(
558                "Netrelation feature {} missing 'navigability' property",
559                idx
560            ))
561        })?;
562
563    let (navigable_forward, navigable_backward) = match navigability_str.to_lowercase().as_str() {
564        "both" => (true, true),
565        "ab" => (true, false),
566        "ba" => (false, true),
567        "none" => (false, false),
568        _ => return Err(ProjectionError::GeoJsonError(
569            format!("Netrelation feature {}: invalid navigability value '{}' (expected: both, AB, BA, or none)", idx, navigability_str)
570        )),
571    };
572
573    // Create NetRelation
574    let netrelation = NetRelation::new(
575        id,
576        netelement_a,
577        netelement_b,
578        position_on_a,
579        position_on_b,
580        navigable_forward,
581        navigable_backward,
582    )?;
583
584    Ok(netrelation)
585}
586
587/// Write projected positions as GeoJSON FeatureCollection
588pub fn write_geojson(
589    positions: &[ProjectedPosition],
590    writer: &mut impl std::io::Write,
591) -> Result<(), ProjectionError> {
592    use geojson::{Feature, FeatureCollection, Geometry, Value};
593    use serde_json::{Map, Value as JsonValue};
594
595    let mut features = Vec::new();
596
597    for pos in positions {
598        // Create Point geometry for projected position
599        let geometry = Geometry::new(Value::Point(vec![
600            pos.projected_coords.x(),
601            pos.projected_coords.y(),
602        ]));
603
604        // Create properties
605        let mut properties = Map::new();
606        properties.insert(
607            "original_lat".to_string(),
608            JsonValue::from(pos.original.latitude),
609        );
610        properties.insert(
611            "original_lon".to_string(),
612            JsonValue::from(pos.original.longitude),
613        );
614        properties.insert(
615            "original_time".to_string(),
616            JsonValue::from(pos.original.timestamp.to_rfc3339()),
617        );
618        properties.insert(
619            "netelement_id".to_string(),
620            JsonValue::from(pos.netelement_id.clone()),
621        );
622        properties.insert(
623            "measure_meters".to_string(),
624            JsonValue::from(pos.measure_meters),
625        );
626        properties.insert(
627            "projection_distance_meters".to_string(),
628            JsonValue::from(pos.projection_distance_meters),
629        );
630        properties.insert("crs".to_string(), JsonValue::from(pos.crs.clone()));
631
632        // Add original metadata
633        for (key, value) in &pos.original.metadata {
634            properties.insert(format!("original_{}", key), JsonValue::from(value.clone()));
635        }
636
637        let feature = Feature {
638            bbox: None,
639            geometry: Some(geometry),
640            id: None,
641            properties: Some(properties),
642            foreign_members: None,
643        };
644
645        features.push(feature);
646    }
647
648    let feature_collection = FeatureCollection {
649        bbox: None,
650        features,
651        foreign_members: None,
652    };
653
654    let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
655        ProjectionError::GeoJsonError(format!("Failed to serialize GeoJSON: {}", e))
656    })?;
657
658    writer.write_all(json.as_bytes())?;
659    Ok(())
660}
661
662/// Parse a [`TrainPath`] from a GeoJSON FeatureCollection file.
663///
664/// Expects the format produced by [`write_trainpath_geojson`]: a FeatureCollection where
665/// each Feature carries segment properties (`netelement_id`, `probability`,
666/// `start_intrinsic`, `end_intrinsic`, `gnss_start_index`, `gnss_end_index`).
667/// The FeatureCollection's `properties` object (stored in `foreign_members`) may
668/// optionally contain `overall_probability` (float) and `calculated_at` (RFC3339).
669///
670/// # Errors
671///
672/// Returns [`ProjectionError::GeoJsonError`] when a required property is missing or
673/// has an unexpected type, or when the file is not a valid FeatureCollection.
674pub fn parse_trainpath_geojson(path: &str) -> Result<crate::models::TrainPath, ProjectionError> {
675    use crate::models::AssociatedNetElement;
676
677    let geojson_str = fs::read_to_string(path)?;
678    let geojson = geojson_str.parse::<GeoJson>().map_err(|e| {
679        ProjectionError::GeoJsonError(format!("Failed to parse TrainPath GeoJSON: {}", e))
680    })?;
681
682    let fc = match geojson {
683        GeoJson::FeatureCollection(fc) => fc,
684        _ => {
685            return Err(ProjectionError::GeoJsonError(
686                "TrainPath GeoJSON must be a FeatureCollection".to_string(),
687            ))
688        }
689    };
690
691    // Extract overall_probability and calculated_at from the top-level "properties" object
692    // stored in foreign_members (written by write_trainpath_geojson).
693    let (overall_probability, calculated_at) = fc
694        .foreign_members
695        .as_ref()
696        .and_then(|fm| fm.get("properties"))
697        .and_then(|v| v.as_object())
698        .map(|props| {
699            let prob = props.get("overall_probability").and_then(|v| v.as_f64());
700            let calc_at = props
701                .get("calculated_at")
702                .and_then(|v| v.as_str())
703                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
704                .map(|dt| dt.with_timezone(&chrono::Utc));
705            (prob, calc_at)
706        })
707        .unwrap_or((None, None));
708
709    let mut segments = Vec::new();
710
711    for (idx, feature) in fc.features.iter().enumerate() {
712        let props = feature.properties.as_ref().ok_or_else(|| {
713            ProjectionError::GeoJsonError(format!("TrainPath feature {} missing properties", idx))
714        })?;
715
716        macro_rules! get_str {
717            ($key:expr) => {
718                props
719                    .get($key)
720                    .and_then(|v| v.as_str())
721                    .map(|s| s.to_string())
722                    .ok_or_else(|| {
723                        ProjectionError::GeoJsonError(format!(
724                            "TrainPath feature {} missing or invalid '{}' property",
725                            idx, $key
726                        ))
727                    })
728            };
729        }
730        macro_rules! get_f64 {
731            ($key:expr) => {
732                props.get($key).and_then(|v| v.as_f64()).ok_or_else(|| {
733                    ProjectionError::GeoJsonError(format!(
734                        "TrainPath feature {} missing or invalid '{}' property",
735                        idx, $key
736                    ))
737                })
738            };
739        }
740        macro_rules! get_usize {
741            ($key:expr) => {
742                props
743                    .get($key)
744                    .and_then(|v| v.as_u64())
745                    .map(|v| v as usize)
746                    .ok_or_else(|| {
747                        ProjectionError::GeoJsonError(format!(
748                            "TrainPath feature {} missing or invalid '{}' property",
749                            idx, $key
750                        ))
751                    })
752            };
753        }
754
755        let segment = AssociatedNetElement::new(
756            get_str!("netelement_id")?,
757            get_f64!("probability")?,
758            get_f64!("start_intrinsic")?,
759            get_f64!("end_intrinsic")?,
760            get_usize!("gnss_start_index")?,
761            get_usize!("gnss_end_index")?,
762        )?;
763
764        segments.push(segment);
765    }
766
767    let overall_prob = overall_probability.unwrap_or_else(|| {
768        if segments.is_empty() {
769            1.0
770        } else {
771            let sum: f64 = segments.iter().map(|s| s.probability).sum();
772            sum / segments.len() as f64
773        }
774    });
775
776    crate::models::TrainPath::new(segments, overall_prob, calculated_at, None)
777}
778
779/// Write TrainPath as GeoJSON FeatureCollection
780///
781/// Serializes a TrainPath to GeoJSON, with each segment as a separate feature.
782/// The overall path probability and metadata are stored in the FeatureCollection properties.
783///
784/// # Arguments
785///
786/// * `train_path` - The TrainPath to serialize
787/// * `netelements` - Map of netelement IDs to Netelement geometries (for creating LineString features)
788/// * `writer` - Output writer
789///
790/// # Output Format
791///
792/// ```json
793/// {
794///   "type": "FeatureCollection",
795///   "properties": {
796///     "overall_probability": 0.89,
797///     "calculated_at": "2025-01-15T10:30:00Z",
798///     "distance_scale": 10.0,
799///     "heading_scale": 2.0
800///   },
801///   "features": [
802///     {
803///       "type": "Feature",
804///       "geometry": { "type": "LineString", "coordinates": [...] },
805///       "properties": {
806///         "netelement_id": "NE_A",
807///         "probability": 0.87,
808///         "start_intrinsic": 0.0,
809///         "end_intrinsic": 1.0,
810///         "gnss_start_index": 0,
811///         "gnss_end_index": 10
812///       }
813///     }
814///   ]
815/// }
816/// ```
817pub fn write_trainpath_geojson(
818    train_path: &crate::models::TrainPath,
819    netelements: &std::collections::HashMap<String, Netelement>,
820    writer: &mut impl std::io::Write,
821) -> Result<(), ProjectionError> {
822    use geojson::{Feature, FeatureCollection, Geometry, Value};
823    use serde_json::{Map, Value as JsonValue};
824
825    let mut features = Vec::new();
826
827    // Create a feature for each segment
828    for segment in &train_path.segments {
829        // Look up the netelement geometry
830        let netelement = netelements.get(&segment.netelement_id).ok_or_else(|| {
831            ProjectionError::InvalidGeometry(format!(
832                "Netelement {} not found in provided map",
833                segment.netelement_id
834            ))
835        })?;
836
837        // Extract the portion of the linestring covered by this segment
838        // For simplicity, use the full linestring geometry
839        // (In a production system, you'd extract the substring from start_intrinsic to end_intrinsic)
840        let coords: Vec<Vec<f64>> = netelement
841            .geometry
842            .points()
843            .map(|point| vec![point.x(), point.y()])
844            .collect();
845
846        let geometry = Geometry::new(Value::LineString(coords));
847
848        // Create properties for this segment
849        let mut properties = Map::new();
850        properties.insert(
851            "netelement_id".to_string(),
852            JsonValue::from(segment.netelement_id.clone()),
853        );
854        properties.insert(
855            "probability".to_string(),
856            JsonValue::from(segment.probability),
857        );
858        properties.insert(
859            "start_intrinsic".to_string(),
860            JsonValue::from(segment.start_intrinsic),
861        );
862        properties.insert(
863            "end_intrinsic".to_string(),
864            JsonValue::from(segment.end_intrinsic),
865        );
866        properties.insert(
867            "gnss_start_index".to_string(),
868            JsonValue::from(segment.gnss_start_index as i64),
869        );
870        properties.insert(
871            "gnss_end_index".to_string(),
872            JsonValue::from(segment.gnss_end_index as i64),
873        );
874
875        let feature = Feature {
876            bbox: None,
877            geometry: Some(geometry),
878            id: None,
879            properties: Some(properties),
880            foreign_members: None,
881        };
882
883        features.push(feature);
884    }
885
886    // Create FeatureCollection with overall properties
887    let mut fc_properties = Map::new();
888    fc_properties.insert(
889        "overall_probability".to_string(),
890        JsonValue::from(train_path.overall_probability),
891    );
892
893    if let Some(calculated_at) = &train_path.calculated_at {
894        fc_properties.insert(
895            "calculated_at".to_string(),
896            JsonValue::from(calculated_at.to_rfc3339()),
897        );
898    }
899
900    // Add metadata if present
901    if let Some(metadata) = &train_path.metadata {
902        fc_properties.insert(
903            "distance_scale".to_string(),
904            JsonValue::from(metadata.distance_scale),
905        );
906        fc_properties.insert(
907            "heading_scale".to_string(),
908            JsonValue::from(metadata.heading_scale),
909        );
910        fc_properties.insert(
911            "cutoff_distance".to_string(),
912            JsonValue::from(metadata.cutoff_distance),
913        );
914        fc_properties.insert(
915            "heading_cutoff".to_string(),
916            JsonValue::from(metadata.heading_cutoff),
917        );
918        fc_properties.insert(
919            "probability_threshold".to_string(),
920            JsonValue::from(metadata.probability_threshold),
921        );
922        if let Some(resampling_dist) = metadata.resampling_distance {
923            fc_properties.insert(
924                "resampling_distance".to_string(),
925                JsonValue::from(resampling_dist),
926            );
927        }
928        fc_properties.insert(
929            "fallback_mode".to_string(),
930            JsonValue::from(metadata.fallback_mode),
931        );
932        fc_properties.insert(
933            "candidate_paths_evaluated".to_string(),
934            JsonValue::from(metadata.candidate_paths_evaluated as i64),
935        );
936        fc_properties.insert(
937            "bidirectional_path".to_string(),
938            JsonValue::from(metadata.bidirectional_path),
939        );
940    }
941
942    let mut foreign_members = Map::new();
943    foreign_members.insert("properties".to_string(), JsonValue::Object(fc_properties));
944
945    let feature_collection = FeatureCollection {
946        bbox: None,
947        features,
948        foreign_members: Some(foreign_members),
949    };
950
951    let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
952        ProjectionError::GeoJsonError(format!("Failed to serialize TrainPath GeoJSON: {}", e))
953    })?;
954
955    writer.write_all(json.as_bytes())?;
956    Ok(())
957}
958
959#[cfg(test)]
960mod tests;