Skip to main content

tp_lib_core/path/
debug.rs

1//! Debug information export utilities for path calculation (US7)
2//!
3//! This module provides functions to export intermediate HMM calculation results
4//! for troubleshooting and parameter tuning.
5//!
6//! Output files are numbered by phase:
7//! 1. `01_emission_probabilities.geojson` — Emission probabilities: links from each GNSS
8//!    position to its candidate netelements with distance / heading probabilities.
9//! 2. `02_transition_probabilities.geojson` — Transition probabilities between every
10//!    feasible (non-zero) candidate pair across consecutive GNSS steps.
11//! 3. `03_viterbi_trace.geojson` — Viterbi decoding trace: the netelement selected at
12//!    each observation step.
13//! 4. `04_candidate_netelements.geojson` — All candidate netelements with aggregate
14//!    emission probabilities and Viterbi membership flag.
15//! 5. `05_path_sanity_decisions.geojson` — Post-Viterbi navigability sanity check
16//!    decisions for each consecutive segment pair.
17//! 6. `06_filling_gaps.geojson` — Gap-fill decisions: bridge netelements inserted
18//!    between disconnected consecutive segments after sanity validation.
19//! 7. `07_selected_path.geojson` — Only the netelements that form the final validated
20//!    path (including bridge segments).
21use crate::errors::ProjectionError;
22use crate::path::DebugInfo;
23use std::fs::File;
24use std::io::Write;
25use std::path::Path;
26
27/// Export all HMM debug information to numbered GeoJSON files (T158)
28///
29/// Writes seven phase-numbered files to `output_dir`:
30/// - `01_emission_probabilities.geojson`
31/// - `02_transition_probabilities.geojson`
32/// - `03_viterbi_trace.geojson`
33/// - `04_candidate_netelements.geojson`
34/// - `05_path_sanity_decisions.geojson`
35/// - `06_filling_gaps.geojson`
36/// - `07_selected_path.geojson`
37pub fn export_all_debug_info<P: AsRef<Path>>(
38    debug_info: &DebugInfo,
39    output_dir: P,
40) -> Result<(), ProjectionError> {
41    let dir = output_dir.as_ref();
42    std::fs::create_dir_all(dir)?;
43
44    if !debug_info.position_candidates.is_empty() {
45        export_hmm_emission_probabilities(
46            debug_info,
47            dir.join("01_emission_probabilities.geojson"),
48        )?;
49    }
50
51    if !debug_info.decision_tree.is_empty() {
52        export_hmm_viterbi_trace(debug_info, dir.join("03_viterbi_trace.geojson"))?;
53    }
54
55    if !debug_info.netelement_probabilities.is_empty() {
56        export_hmm_candidate_netelements(debug_info, dir.join("04_candidate_netelements.geojson"))?;
57        export_hmm_selected_path(debug_info, dir.join("07_selected_path.geojson"))?;
58    }
59
60    if !debug_info.sanity_decisions.is_empty() {
61        export_path_sanity_decisions(debug_info, dir.join("05_path_sanity_decisions.geojson"))?;
62    }
63
64    if !debug_info.gap_fills.is_empty() {
65        export_gap_fills(debug_info, dir.join("06_filling_gaps.geojson"))?;
66    }
67
68    if !debug_info.transition_probabilities.is_empty() {
69        export_hmm_transition_probabilities(
70            debug_info,
71            dir.join("02_transition_probabilities.geojson"),
72        )?;
73    }
74
75    Ok(())
76}
77
78/// Export Phase 1 – HMM emission probabilities as GeoJSON
79///
80/// Produces a FeatureCollection with one LineString per GNSS-position × candidate
81/// netelement pair, recording the emission probability components so that the HMM
82/// observation model can be inspected spatially.
83///
84/// Properties per feature:
85/// - `step`                   – GNSS position index (0-based)
86/// - `netelement_id`          – candidate netelement
87/// - `emission_probability`   – combined (distance × heading) emission probability
88/// - `distance_probability`   – distance component
89/// - `distance_m`             – absolute distance in metres
90/// - `heading_probability`    – heading component (omitted when unavailable)
91/// - `heading_difference_deg` – absolute heading difference in degrees (omitted when unavailable)
92/// - `status`                 – `"selected"`, `"candidate"`, or `"rejected"`
93pub fn export_hmm_emission_probabilities<P: AsRef<Path>>(
94    debug_info: &DebugInfo,
95    output_path: P,
96) -> Result<(), ProjectionError> {
97    use geojson::{Feature, FeatureCollection, Geometry, Value};
98    use serde_json::{Map, Value as JsonValue};
99
100    let mut features = Vec::new();
101
102    for pos in &debug_info.position_candidates {
103        for candidate in &pos.candidates {
104            let line_geom = Geometry::new(Value::LineString(vec![
105                vec![pos.coordinates.1, pos.coordinates.0],
106                vec![candidate.projected_lon, candidate.projected_lat],
107            ]));
108            let mut props = Map::new();
109            props.insert(
110                "step".to_string(),
111                JsonValue::from(pos.position_index as i64),
112            );
113            props.insert(
114                "netelement_id".to_string(),
115                JsonValue::from(candidate.netelement_id.clone()),
116            );
117            props.insert(
118                "emission_probability".to_string(),
119                JsonValue::from(candidate.combined_probability),
120            );
121            props.insert(
122                "distance_probability".to_string(),
123                JsonValue::from(candidate.distance_probability),
124            );
125            props.insert(
126                "distance_m".to_string(),
127                JsonValue::from(candidate.distance),
128            );
129            if let Some(hp) = candidate.heading_probability {
130                props.insert("heading_probability".to_string(), JsonValue::from(hp));
131            }
132            if let Some(hd) = candidate.heading_difference {
133                props.insert("heading_difference_deg".to_string(), JsonValue::from(hd));
134            }
135            props.insert(
136                "status".to_string(),
137                JsonValue::from(candidate.status.clone()),
138            );
139            features.push(Feature {
140                bbox: None,
141                geometry: Some(line_geom),
142                id: None,
143                properties: Some(props),
144                foreign_members: None,
145            });
146        }
147    }
148
149    let mut fc_members = serde_json::Map::new();
150    fc_members.insert("phase".to_string(), JsonValue::from(1i64));
151    fc_members.insert(
152        "description".to_string(),
153        JsonValue::from(
154            "HMM emission probabilities: links from each GNSS position to its candidate netelements",
155        ),
156    );
157
158    let fc = FeatureCollection {
159        bbox: None,
160        features,
161        foreign_members: Some(fc_members),
162    };
163    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
164        ProjectionError::InvalidGeometry(format!(
165            "Failed to serialize emission probabilities GeoJSON: {}",
166            e
167        ))
168    })?;
169    let mut file = File::create(output_path.as_ref())?;
170    file.write_all(json.as_bytes())?;
171    Ok(())
172}
173
174/// Export Phase 3 – Viterbi decoding trace as GeoJSON
175///
176/// Produces a FeatureCollection with one LineString feature per HMM decoding step
177/// (one per GNSS observation), linking the raw GNSS point to the projected point on
178/// the netelement chosen by the Viterbi algorithm at that step.  Features with no
179/// matching candidate are emitted with `null` geometry so they still appear in
180/// attribute tables.
181///
182/// Properties per feature:
183/// - `step`                 â€" observation index (0-based)
184/// - `netelement_id`        â€" the netelement chosen at this step
185/// - `decision_type`        â€" type of Viterbi event (`"viterbi_init"` or `"viterbi_transition"`)
186/// - `selected_probability` â€" emission probability of the chosen candidate (when available)
187/// - `alternatives_count`   – number of alternatives considered
188/// - `reason`               – human-readable selection rationale
189pub fn export_hmm_viterbi_trace<P: AsRef<Path>>(
190    debug_info: &DebugInfo,
191    output_path: P,
192) -> Result<(), ProjectionError> {
193    use geojson::{Feature, FeatureCollection, Geometry, Value};
194    use serde_json::{Map, Value as JsonValue};
195
196    let pos_lookup: std::collections::HashMap<usize, &crate::path::PositionCandidates> = debug_info
197        .position_candidates
198        .iter()
199        .map(|pc| (pc.position_index, pc))
200        .collect();
201
202    let mut features = Vec::new();
203
204    for decision in &debug_info.decision_tree {
205        let (geometry, selected_probability) = match pos_lookup.get(&decision.step) {
206            Some(pos) => {
207                match pos
208                    .candidates
209                    .iter()
210                    .find(|c| c.netelement_id == decision.chosen_option)
211                {
212                    Some(c) => {
213                        let geom = Geometry::new(Value::LineString(vec![
214                            vec![pos.coordinates.1, pos.coordinates.0],
215                            vec![c.projected_lon, c.projected_lat],
216                        ]));
217                        (Some(geom), Some(c.combined_probability))
218                    }
219                    None => (None, None),
220                }
221            }
222            None => (None, None),
223        };
224
225        let mut props = Map::new();
226        props.insert("step".to_string(), JsonValue::from(decision.step as i64));
227        props.insert(
228            "netelement_id".to_string(),
229            JsonValue::from(decision.chosen_option.clone()),
230        );
231        props.insert(
232            "decision_type".to_string(),
233            JsonValue::from(decision.decision_type.clone()),
234        );
235        if let Some(prob) = selected_probability {
236            props.insert("selected_probability".to_string(), JsonValue::from(prob));
237        }
238        props.insert(
239            "alternatives_count".to_string(),
240            JsonValue::from(decision.options.len() as i64),
241        );
242        props.insert(
243            "reason".to_string(),
244            JsonValue::from(decision.reason.clone()),
245        );
246
247        features.push(Feature {
248            bbox: None,
249            geometry,
250            id: None,
251            properties: Some(props),
252            foreign_members: None,
253        });
254    }
255
256    let mut fc_members = serde_json::Map::new();
257    fc_members.insert("phase".to_string(), JsonValue::from(3i64));
258    fc_members.insert(
259        "description".to_string(),
260        JsonValue::from(
261            "HMM Viterbi decoding trace: links from each GNSS position to the chosen netelement",
262        ),
263    );
264
265    let fc = FeatureCollection {
266        bbox: None,
267        features,
268        foreign_members: Some(fc_members),
269    };
270    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
271        ProjectionError::InvalidGeometry(format!(
272            "Failed to serialize Viterbi trace GeoJSON: {}",
273            e
274        ))
275    })?;
276    let mut file = File::create(output_path.as_ref())?;
277    file.write_all(json.as_bytes())?;
278    Ok(())
279}
280
281/// Export Phase 4 – All candidate netelements with aggregate probabilities as GeoJSON
282///
283/// Produces a FeatureCollection with LineString features for every netelement that was
284/// considered as an HMM candidate state, annotated with aggregate emission probabilities
285/// and a flag indicating Viterbi path membership.
286///
287/// Properties per feature:
288/// - `netelement_id`            – netelement identifier
289/// - `avg_emission_probability` – average emission probability across matched positions
290/// - `position_count`           – number of GNSS positions for which this was a candidate
291/// - `in_viterbi_path`          – whether this netelement is part of the decoded path
292/// - `is_bridge`                – whether this segment was inserted as a topological bridge
293pub fn export_hmm_candidate_netelements<P: AsRef<Path>>(
294    debug_info: &DebugInfo,
295    output_path: P,
296) -> Result<(), ProjectionError> {
297    use geojson::{Feature, FeatureCollection, Geometry, Value};
298    use serde_json::{Map, Value as JsonValue};
299
300    let mut features = Vec::new();
301
302    for ne in &debug_info.netelement_probabilities {
303        if ne.geometry_coords.len() < 2 {
304            continue;
305        }
306        let geom = Geometry::new(Value::LineString(ne.geometry_coords.clone()));
307        let mut props = Map::new();
308        props.insert(
309            "netelement_id".to_string(),
310            JsonValue::from(ne.netelement_id.clone()),
311        );
312        props.insert(
313            "avg_emission_probability".to_string(),
314            JsonValue::from(ne.avg_emission_probability),
315        );
316        props.insert(
317            "position_count".to_string(),
318            JsonValue::from(ne.position_count as i64),
319        );
320        props.insert(
321            "in_viterbi_path".to_string(),
322            JsonValue::from(ne.in_viterbi_path),
323        );
324        props.insert("is_bridge".to_string(), JsonValue::from(ne.is_bridge));
325        features.push(Feature {
326            bbox: None,
327            geometry: Some(geom),
328            id: None,
329            properties: Some(props),
330            foreign_members: None,
331        });
332    }
333
334    let mut fc_members = serde_json::Map::new();
335    fc_members.insert("phase".to_string(), JsonValue::from(4i64));
336    fc_members.insert(
337        "description".to_string(),
338        JsonValue::from("HMM candidate netelements: all states considered during Viterbi decoding"),
339    );
340
341    let fc = FeatureCollection {
342        bbox: None,
343        features,
344        foreign_members: Some(fc_members),
345    };
346    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
347        ProjectionError::InvalidGeometry(format!(
348            "Failed to serialize candidate netelements GeoJSON: {}",
349            e
350        ))
351    })?;
352    let mut file = File::create(output_path.as_ref())?;
353    file.write_all(json.as_bytes())?;
354    Ok(())
355}
356
357/// Export Phase 5 – Selected Viterbi path netelements as GeoJSON
358///
359/// Produces a FeatureCollection with LineString features only for the netelements
360/// that appear in the final Viterbi path (including topological bridge segments).
361///
362/// Properties per feature:
363/// - `netelement_id`            – netelement identifier
364/// - `avg_emission_probability` – average emission probability (0 for bridges)
365/// - `position_count`           – number of GNSS positions associated (0 for bridges)
366/// - `is_bridge`                – whether this segment is a topological bridge
367pub fn export_hmm_selected_path<P: AsRef<Path>>(
368    debug_info: &DebugInfo,
369    output_path: P,
370) -> Result<(), ProjectionError> {
371    use geojson::{Feature, FeatureCollection, Geometry, Value};
372    use serde_json::{Map, Value as JsonValue};
373
374    let mut features = Vec::new();
375
376    for ne in &debug_info.netelement_probabilities {
377        if !ne.in_viterbi_path {
378            continue;
379        }
380        if ne.geometry_coords.len() < 2 {
381            continue;
382        }
383        let geom = Geometry::new(Value::LineString(ne.geometry_coords.clone()));
384        let mut props = Map::new();
385        props.insert(
386            "netelement_id".to_string(),
387            JsonValue::from(ne.netelement_id.clone()),
388        );
389        props.insert(
390            "avg_emission_probability".to_string(),
391            JsonValue::from(ne.avg_emission_probability),
392        );
393        props.insert(
394            "position_count".to_string(),
395            JsonValue::from(ne.position_count as i64),
396        );
397        props.insert("is_bridge".to_string(), JsonValue::from(ne.is_bridge));
398        features.push(Feature {
399            bbox: None,
400            geometry: Some(geom),
401            id: None,
402            properties: Some(props),
403            foreign_members: None,
404        });
405    }
406
407    let mut fc_members = serde_json::Map::new();
408    fc_members.insert("phase".to_string(), JsonValue::from(6i64));
409    fc_members.insert(
410        "description".to_string(),
411        JsonValue::from("HMM selected path: netelements in the final validated path"),
412    );
413
414    let fc = FeatureCollection {
415        bbox: None,
416        features,
417        foreign_members: Some(fc_members),
418    };
419    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
420        ProjectionError::InvalidGeometry(format!(
421            "Failed to serialize selected path GeoJSON: {}",
422            e
423        ))
424    })?;
425    let mut file = File::create(output_path.as_ref())?;
426    file.write_all(json.as_bytes())?;
427    Ok(())
428}
429
430/// Export Phase 2 â€" HMM transition probabilities as GeoJSON
431///
432/// Produces a FeatureCollection with one LineString feature per feasible
433/// (non-zero) candidate-pair transition across consecutive GNSS observations.
434/// Each feature links the projected point of the preceding candidate to the
435/// projected point of the succeeding candidate, so that connectivity gaps
436/// and long transitions stand out visually.
437///
438/// Only transitions with a non-zero probability are included; impossible
439/// transitions (disconnected network paths, edge-zone constraints) are
440/// omitted.
441///
442/// Properties per feature:
443/// - `from_step`              â€" observation index of the preceding position (0-based)
444/// - `to_step`                â€" observation index of the succeeding position (0-based)
445/// - `from_netelement_id`     â€" netelement of the preceding candidate
446/// - `to_netelement_id`       â€" netelement of the succeeding candidate
447/// - `transition_probability` â€" linear-scale transition probability [0, 1]
448/// - `is_viterbi_chosen`      â€" whether this pair was chosen by the Viterbi algorithm
449pub fn export_hmm_transition_probabilities<P: AsRef<Path>>(
450    debug_info: &DebugInfo,
451    output_path: P,
452) -> Result<(), ProjectionError> {
453    use geojson::{Feature, FeatureCollection, Geometry, Value};
454    use serde_json::{Map, Value as JsonValue};
455
456    // Build lookup: (position_index, netelement_id) -> (projected_lon, projected_lat)
457    let mut point_lookup: std::collections::HashMap<(usize, &str), (f64, f64)> =
458        std::collections::HashMap::new();
459    for pos in &debug_info.position_candidates {
460        for c in &pos.candidates {
461            point_lookup.insert(
462                (pos.position_index, c.netelement_id.as_str()),
463                (c.projected_lon, c.projected_lat),
464            );
465        }
466    }
467
468    let mut features = Vec::new();
469
470    for entry in &debug_info.transition_probabilities {
471        let from_pt = point_lookup.get(&(entry.from_step, entry.from_netelement_id.as_str()));
472        let to_pt = point_lookup.get(&(entry.to_step, entry.to_netelement_id.as_str()));
473        let geometry = match (from_pt, to_pt) {
474            (Some(&(from_lon, from_lat)), Some(&(to_lon, to_lat))) => {
475                Some(Geometry::new(Value::LineString(vec![
476                    vec![from_lon, from_lat],
477                    vec![to_lon, to_lat],
478                ])))
479            }
480            _ => None,
481        };
482
483        let mut props = Map::new();
484        props.insert(
485            "from_step".to_string(),
486            JsonValue::from(entry.from_step as i64),
487        );
488        props.insert("to_step".to_string(), JsonValue::from(entry.to_step as i64));
489        props.insert(
490            "from_netelement_id".to_string(),
491            JsonValue::from(entry.from_netelement_id.clone()),
492        );
493        props.insert(
494            "to_netelement_id".to_string(),
495            JsonValue::from(entry.to_netelement_id.clone()),
496        );
497        props.insert(
498            "transition_probability".to_string(),
499            JsonValue::from(entry.transition_probability),
500        );
501        props.insert(
502            "is_viterbi_chosen".to_string(),
503            JsonValue::from(entry.is_viterbi_chosen),
504        );
505
506        features.push(Feature {
507            bbox: None,
508            geometry,
509            id: None,
510            properties: Some(props),
511            foreign_members: None,
512        });
513    }
514
515    let mut fc_members = serde_json::Map::new();
516    fc_members.insert("phase".to_string(), JsonValue::from(2i64));
517    fc_members.insert(
518        "description".to_string(),
519        JsonValue::from(
520            "HMM transition probabilities: feasible candidate-pair links across consecutive GNSS steps",
521        ),
522    );
523
524    let fc = FeatureCollection {
525        bbox: None,
526        features,
527        foreign_members: Some(fc_members),
528    };
529    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
530        ProjectionError::InvalidGeometry(format!(
531            "Failed to serialize transition probabilities GeoJSON: {}",
532            e
533        ))
534    })?;
535    let mut file = File::create(output_path.as_ref())?;
536    file.write_all(json.as_bytes())?;
537    Ok(())
538}
539
540/// Export Phase 5 — Post-Viterbi path sanity decisions as GeoJSON
541///
542/// Produces a FeatureCollection with one Point feature per consecutive
543/// segment pair evaluated during navigability validation.  Each feature
544/// is placed at the midpoint of the from-netelement's geometry.
545///
546/// Properties per feature:
547/// - `pair_index`          — sequential index of the pair (0-based)
548/// - `from_netelement_id`  — source netelement
549/// - `to_netelement_id`    — target netelement
550/// - `reachable`           — whether the target was reachable
551/// - `action`              — "kept", "removed", or "rerouted"
552/// - `rerouted_via`        — comma-separated NE IDs of bridge segments (empty if N/A)
553/// - `warning`             — warning message (empty if reachable)
554pub fn export_path_sanity_decisions<P: AsRef<Path>>(
555    debug_info: &DebugInfo,
556    output_path: P,
557) -> Result<(), ProjectionError> {
558    use geojson::{Feature, FeatureCollection, Geometry, Value};
559    use serde_json::{Map, Value as JsonValue};
560    use std::collections::HashMap;
561
562    // Build a lookup from netelement ID to geometry coords for spatial placement.
563    let ne_geom: HashMap<&str, &Vec<Vec<f64>>> = debug_info
564        .netelement_probabilities
565        .iter()
566        .map(|np| (np.netelement_id.as_str(), &np.geometry_coords))
567        .collect();
568
569    let mut features = Vec::new();
570
571    for decision in &debug_info.sanity_decisions {
572        // Place the point at the midpoint of the from-netelement's geometry.
573        let coords = ne_geom
574            .get(decision.from_netelement_id.as_str())
575            .and_then(|g| {
576                if g.is_empty() {
577                    return None;
578                }
579                let mid = g.len() / 2;
580                Some(vec![g[mid][0], g[mid][1]])
581            })
582            .unwrap_or_else(|| vec![0.0, 0.0]);
583        let geom = Geometry::new(Value::Point(coords));
584        let mut props = Map::new();
585        props.insert(
586            "pair_index".to_string(),
587            JsonValue::from(decision.pair_index as i64),
588        );
589        props.insert(
590            "from_netelement_id".to_string(),
591            JsonValue::from(decision.from_netelement_id.clone()),
592        );
593        props.insert(
594            "to_netelement_id".to_string(),
595            JsonValue::from(decision.to_netelement_id.clone()),
596        );
597        props.insert("reachable".to_string(), JsonValue::from(decision.reachable));
598        props.insert(
599            "action".to_string(),
600            JsonValue::from(decision.action.clone()),
601        );
602        props.insert(
603            "rerouted_via".to_string(),
604            JsonValue::from(decision.rerouted_via.join(",")),
605        );
606        props.insert(
607            "warning".to_string(),
608            JsonValue::from(decision.warning.clone()),
609        );
610
611        features.push(Feature {
612            bbox: None,
613            geometry: Some(geom),
614            id: None,
615            properties: Some(props),
616            foreign_members: None,
617        });
618    }
619
620    let mut fc_members = serde_json::Map::new();
621    fc_members.insert("phase".to_string(), JsonValue::from(5i64));
622    fc_members.insert(
623        "description".to_string(),
624        JsonValue::from(
625            "Path sanity decisions: post-Viterbi navigability validation for each consecutive segment pair",
626        ),
627    );
628
629    let fc = FeatureCollection {
630        bbox: None,
631        features,
632        foreign_members: Some(fc_members),
633    };
634    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
635        ProjectionError::InvalidGeometry(format!(
636            "Failed to serialize path sanity decisions GeoJSON: {}",
637            e
638        ))
639    })?;
640    let mut file = File::create(output_path.as_ref())?;
641    file.write_all(json.as_bytes())?;
642    Ok(())
643}
644
645/// Export gap-fill records to a GeoJSON file (phase 6).
646///
647/// Each gap-fill decision becomes a Point feature at the midpoint of the
648/// from-netelement's geometry, with tabular properties
649/// describing the pair, whether a route was found, and which bridge NEs were inserted.
650pub fn export_gap_fills<P: AsRef<Path>>(
651    debug_info: &DebugInfo,
652    output_path: P,
653) -> Result<(), ProjectionError> {
654    use geojson::{Feature, FeatureCollection, Geometry, JsonObject, Value as GeoValue};
655    use serde_json::Value as JsonValue;
656
657    // Build a lookup from netelement ID to geometry coords for spatial placement.
658    let ne_geom: std::collections::HashMap<&str, &Vec<Vec<f64>>> = debug_info
659        .netelement_probabilities
660        .iter()
661        .map(|np| (np.netelement_id.as_str(), &np.geometry_coords))
662        .collect();
663
664    let mut features = Vec::new();
665
666    for gf in &debug_info.gap_fills {
667        // Place the point at the midpoint of the from-netelement's geometry.
668        let coords = ne_geom
669            .get(gf.from_netelement_id.as_str())
670            .and_then(|g| {
671                if g.is_empty() {
672                    return None;
673                }
674                let mid = g.len() / 2;
675                Some(vec![g[mid][0], g[mid][1]])
676            })
677            .unwrap_or_else(|| vec![0.0, 0.0]);
678        let geom = Geometry::new(GeoValue::Point(coords));
679        let mut props = JsonObject::new();
680        props.insert(
681            "pair_index".to_string(),
682            JsonValue::from(gf.pair_index as u64),
683        );
684        props.insert(
685            "from_netelement_id".to_string(),
686            JsonValue::from(gf.from_netelement_id.as_str()),
687        );
688        props.insert(
689            "to_netelement_id".to_string(),
690            JsonValue::from(gf.to_netelement_id.as_str()),
691        );
692        props.insert("route_found".to_string(), JsonValue::from(gf.route_found));
693        props.insert(
694            "inserted_netelements".to_string(),
695            JsonValue::from(gf.inserted_netelements.join(", ")),
696        );
697        props.insert(
698            "inserted_count".to_string(),
699            JsonValue::from(gf.inserted_netelements.len() as u64),
700        );
701        props.insert("warning".to_string(), JsonValue::from(gf.warning.as_str()));
702
703        features.push(Feature {
704            bbox: None,
705            geometry: Some(geom),
706            id: None,
707            properties: Some(props),
708            foreign_members: None,
709        });
710    }
711
712    let mut fc_members = JsonObject::new();
713    fc_members.insert("phase".to_string(), JsonValue::from(6));
714    fc_members.insert(
715        "description".to_string(),
716        JsonValue::from(
717            "Gap filling: bridge netelements inserted between disconnected consecutive segments after sanity validation",
718        ),
719    );
720
721    let fc = FeatureCollection {
722        bbox: None,
723        features,
724        foreign_members: Some(fc_members),
725    };
726    let json = serde_json::to_string_pretty(&fc).map_err(|e| {
727        ProjectionError::InvalidGeometry(format!("Failed to serialize gap fills GeoJSON: {}", e))
728    })?;
729    let mut file = File::create(output_path.as_ref())?;
730    file.write_all(json.as_bytes())?;
731    Ok(())
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::path::{
738        CandidateInfo, NetelementProbabilityInfo, PathDecision, PositionCandidates,
739        TransitionProbabilityEntry,
740    };
741    use std::io::Read;
742
743    fn make_debug_info() -> DebugInfo {
744        let mut debug_info = DebugInfo::new();
745        debug_info.add_position_candidates(PositionCandidates {
746            position_index: 0,
747            timestamp: "2025-01-09T12:00:00Z".to_string(),
748            coordinates: (50.85, 4.35),
749            candidates: vec![CandidateInfo {
750                netelement_id: "NE_A".to_string(),
751                distance: 5.0,
752                heading_difference: Some(2.0),
753                distance_probability: 0.9,
754                heading_probability: Some(0.8),
755                combined_probability: 0.72,
756                status: "selected".to_string(),
757                projected_lat: 50.851,
758                projected_lon: 4.351,
759            }],
760            selected_netelement: Some("NE_A".to_string()),
761        });
762        debug_info.add_decision(PathDecision {
763            step: 0,
764            decision_type: "viterbi_transition".to_string(),
765            current_segment: "NE_A".to_string(),
766            options: vec!["NE_A".to_string()],
767            option_probabilities: vec![0.72],
768            chosen_option: "NE_A".to_string(),
769            reason: "Only candidate".to_string(),
770        });
771        debug_info
772            .netelement_probabilities
773            .push(NetelementProbabilityInfo {
774                netelement_id: "NE_A".to_string(),
775                avg_emission_probability: 0.72,
776                position_count: 1,
777                geometry_coords: vec![vec![4.35, 50.85], vec![4.36, 50.86]],
778                in_viterbi_path: true,
779                is_bridge: false,
780            });
781        debug_info
782            .transition_probabilities
783            .push(TransitionProbabilityEntry {
784                from_step: 0,
785                to_step: 1,
786                from_netelement_id: "NE_A".to_string(),
787                to_netelement_id: "NE_B".to_string(),
788                transition_probability: 0.65,
789                is_viterbi_chosen: true,
790            });
791        debug_info
792    }
793
794    #[test]
795    fn test_export_hmm_emission_probabilities() {
796        let debug_info = make_debug_info();
797        let temp_dir = std::env::temp_dir();
798        let output_path = temp_dir.join("test_hmm_emission.geojson");
799
800        let result = export_hmm_emission_probabilities(&debug_info, &output_path);
801        assert!(result.is_ok());
802
803        let mut file = File::open(&output_path).unwrap();
804        let mut contents = String::new();
805        file.read_to_string(&mut contents).unwrap();
806        assert!(contents.contains("NE_A"));
807        assert!(contents.contains("emission_probability"));
808        assert!(contents.contains("distance_m"));
809        // Should NOT contain raw gnss_position point features
810        assert!(!contents.contains("gnss_position"));
811
812        std::fs::remove_file(&output_path).ok();
813    }
814
815    #[test]
816    fn test_export_hmm_viterbi_trace() {
817        let debug_info = make_debug_info();
818        let temp_dir = std::env::temp_dir();
819        let output_path = temp_dir.join("test_hmm_viterbi_trace.geojson");
820
821        let result = export_hmm_viterbi_trace(&debug_info, &output_path);
822        assert!(result.is_ok());
823
824        let mut file = File::open(&output_path).unwrap();
825        let mut contents = String::new();
826        file.read_to_string(&mut contents).unwrap();
827        assert!(contents.contains("NE_A"));
828        assert!(contents.contains("viterbi_transition"));
829        assert!(contents.contains("netelement_id"));
830
831        std::fs::remove_file(&output_path).ok();
832    }
833
834    #[test]
835    fn test_export_hmm_candidate_netelements() {
836        let debug_info = make_debug_info();
837        let temp_dir = std::env::temp_dir();
838        let output_path = temp_dir.join("test_hmm_candidates.geojson");
839
840        let result = export_hmm_candidate_netelements(&debug_info, &output_path);
841        assert!(result.is_ok());
842
843        let mut file = File::open(&output_path).unwrap();
844        let mut contents = String::new();
845        file.read_to_string(&mut contents).unwrap();
846        assert!(contents.contains("NE_A"));
847        assert!(contents.contains("in_viterbi_path"));
848
849        std::fs::remove_file(&output_path).ok();
850    }
851
852    #[test]
853    fn test_export_hmm_selected_path() {
854        let debug_info = make_debug_info();
855        let temp_dir = std::env::temp_dir();
856        let output_path = temp_dir.join("test_hmm_selected_path.geojson");
857
858        let result = export_hmm_selected_path(&debug_info, &output_path);
859        assert!(result.is_ok());
860
861        let mut file = File::open(&output_path).unwrap();
862        let mut contents = String::new();
863        file.read_to_string(&mut contents).unwrap();
864        assert!(contents.contains("NE_A"));
865        assert!(contents.contains("is_bridge"));
866
867        std::fs::remove_file(&output_path).ok();
868    }
869
870    #[test]
871    fn test_export_all_debug_info() {
872        let debug_info = make_debug_info();
873        let temp_dir = std::env::temp_dir().join("tp_hmm_debug_test");
874
875        let result = export_all_debug_info(&debug_info, &temp_dir);
876        assert!(result.is_ok());
877
878        assert!(temp_dir.join("01_emission_probabilities.geojson").exists());
879        assert!(temp_dir.join("03_viterbi_trace.geojson").exists());
880        assert!(temp_dir.join("04_candidate_netelements.geojson").exists());
881        assert!(temp_dir.join("07_selected_path.geojson").exists());
882        assert!(temp_dir
883            .join("02_transition_probabilities.geojson")
884            .exists());
885
886        std::fs::remove_dir_all(&temp_dir).ok();
887    }
888
889    #[test]
890    fn test_export_hmm_transition_probabilities() {
891        let debug_info = make_debug_info();
892        let temp_dir = std::env::temp_dir();
893        let output_path = temp_dir.join("test_hmm_transition_probs.geojson");
894
895        let result = export_hmm_transition_probabilities(&debug_info, &output_path);
896        assert!(result.is_ok());
897
898        let mut file = File::open(&output_path).unwrap();
899        let mut contents = String::new();
900        file.read_to_string(&mut contents).unwrap();
901        assert!(contents.contains("NE_A"));
902        assert!(contents.contains("NE_B"));
903        assert!(contents.contains("transition_probability"));
904        assert!(contents.contains("is_viterbi_chosen"));
905
906        std::fs::remove_file(&output_path).ok();
907    }
908}