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