Skip to main content

tp_lib_core/io/geojson/
detections.rs

1//! GeoJSON parser for punctual & linear detections (T010).
2//!
3//! See `specs/004-train-detections/contracts/detections-geojson.md`.
4
5use std::collections::BTreeMap;
6use std::path::Path;
7
8use chrono::{DateTime, FixedOffset};
9use geojson::{GeoJson, Geometry, Value};
10use serde_json::{Map as JsonMap, Value as Json};
11
12use crate::detections::error::DetectionError;
13use crate::models::{
14    Detection, DetectionKind, GeographicLocation, LinearDetection, PunctualDetection,
15    TopologicalLocation,
16};
17
18/// Reserved property names per kind. All others go to `metadata`.
19const PUNCTUAL_RESERVED: &[&str] = &[
20    "kind",
21    "timestamp",
22    "netelement_id",
23    "intrinsic",
24    "crs",
25    "id",
26    "source",
27];
28
29const LINEAR_RESERVED: &[&str] = &[
30    "kind",
31    "t_from",
32    "t_to",
33    "netelement_id",
34    "start_intrinsic",
35    "end_intrinsic",
36    "id",
37    "source",
38];
39
40/// Load detections from a GeoJSON / JSON file.
41pub fn load(path: &Path, expected_kind: DetectionKind) -> Result<Vec<Detection>, DetectionError> {
42    let source_file = path.display().to_string();
43    let raw = std::fs::read_to_string(path)?;
44    load_str(&raw, &source_file, expected_kind)
45}
46
47/// In-memory variant of [`load`] that accepts the full GeoJSON text. Required
48/// by the .NET bindings (FR-012, no temp files).
49pub fn load_str(
50    raw: &str,
51    source_file: &str,
52    expected_kind: DetectionKind,
53) -> Result<Vec<Detection>, DetectionError> {
54    let gj: GeoJson = raw.parse().map_err(|e: geojson::Error| {
55        DetectionError::InvalidSchema(format!("invalid GeoJSON: {e}"))
56    })?;
57    let fc = match gj {
58        GeoJson::FeatureCollection(fc) => fc,
59        _ => {
60            return Err(DetectionError::InvalidSchema(
61                "top-level must be a FeatureCollection".to_string(),
62            ))
63        }
64    };
65
66    let mut out = Vec::with_capacity(fc.features.len());
67    for (idx, feature) in fc.features.into_iter().enumerate() {
68        let source_row = idx;
69        let props = feature.properties.ok_or_else(|| {
70            DetectionError::InvalidSchema(format!("feature[{idx}]: missing 'properties'"))
71        })?;
72
73        let kind_str = require_str(&props, "kind", source_file, source_row)?;
74        let actual_kind = match kind_str.as_str() {
75            "punctual" => DetectionKind::Punctual,
76            "linear" => DetectionKind::Linear,
77            other => {
78                return Err(DetectionError::InvalidSchema(format!(
79                    "feature[{idx}]: unknown kind '{other}'"
80                )))
81            }
82        };
83        if actual_kind != expected_kind {
84            return Err(DetectionError::InvalidSchema(format!(
85                "feature[{idx}]: kind '{kind_str}' does not match expected"
86            )));
87        }
88
89        let detection = match expected_kind {
90            DetectionKind::Punctual => {
91                parse_punctual(&props, feature.geometry.as_ref(), source_file, source_row)?
92            }
93            DetectionKind::Linear => parse_linear(&props, source_file, source_row)?,
94        };
95        out.push(detection);
96    }
97    Ok(out)
98}
99
100fn require_str(
101    props: &JsonMap<String, Json>,
102    key: &str,
103    source_file: &str,
104    source_row: usize,
105) -> Result<String, DetectionError> {
106    match props.get(key) {
107        Some(Json::String(s)) if !s.trim().is_empty() => Ok(s.clone()),
108        Some(_) => Err(DetectionError::InvalidSchema(format!(
109            "feature[{source_row}]: property '{key}' must be a non-empty string"
110        ))),
111        None => Err(DetectionError::InvalidSchema(format!(
112            "feature[{source_row}]: missing required property '{key}' in {source_file}"
113        ))),
114    }
115}
116
117fn opt_str(props: &JsonMap<String, Json>, key: &str) -> Option<String> {
118    match props.get(key) {
119        Some(Json::String(s)) if !s.trim().is_empty() => Some(s.clone()),
120        _ => None,
121    }
122}
123
124fn opt_intrinsic(
125    props: &JsonMap<String, Json>,
126    key: &str,
127    source_file: &str,
128    source_row: usize,
129) -> Result<Option<f64>, DetectionError> {
130    let Some(v) = props.get(key) else {
131        return Ok(None);
132    };
133    let n = v.as_f64().ok_or_else(|| DetectionError::Parse {
134        source_file: source_file.to_string(),
135        source_row,
136        message: format!("'{key}' must be a number"),
137    })?;
138    if !(0.0..=1.0).contains(&n) {
139        return Err(DetectionError::InvalidIntrinsic {
140            source_file: source_file.to_string(),
141            source_row,
142            value: n,
143        });
144    }
145    Ok(Some(n))
146}
147
148fn parse_ts(
149    s: &str,
150    source_file: &str,
151    source_row: usize,
152) -> Result<DateTime<FixedOffset>, DetectionError> {
153    crate::temporal::parse_timestamp_flexible_str(s).map_err(|e| DetectionError::InvalidTimestamp {
154        source_file: source_file.to_string(),
155        source_row,
156        message: format!("'{s}': {e}"),
157    })
158}
159
160fn collect_metadata(props: &JsonMap<String, Json>, reserved: &[&str]) -> BTreeMap<String, String> {
161    let mut map = BTreeMap::new();
162    for (k, v) in props.iter() {
163        if reserved.iter().any(|r| r == k) {
164            continue;
165        }
166        let s = match v {
167            Json::String(s) => s.clone(),
168            Json::Null => continue,
169            other => other.to_string(),
170        };
171        map.insert(k.clone(), s);
172    }
173    map
174}
175
176fn parse_punctual(
177    props: &JsonMap<String, Json>,
178    geom: Option<&Geometry>,
179    source_file: &str,
180    source_row: usize,
181) -> Result<Detection, DetectionError> {
182    let timestamp_s = require_str(props, "timestamp", source_file, source_row)?;
183    let timestamp = parse_ts(&timestamp_s, source_file, source_row)?;
184
185    let netelement_id = opt_str(props, "netelement_id");
186    let intrinsic_value = opt_intrinsic(props, "intrinsic", source_file, source_row)?;
187
188    let coordinates = match geom {
189        None => None,
190        Some(g) => match &g.value {
191            Value::Point(coords) => {
192                if coords.len() < 2 {
193                    return Err(DetectionError::InvalidSchema(format!(
194                        "feature[{source_row}]: Point must have [lon, lat]"
195                    )));
196                }
197                let crs = opt_str(props, "crs").unwrap_or_else(|| "EPSG:4326".to_string());
198                Some(GeographicLocation {
199                    latitude: coords[1],
200                    longitude: coords[0],
201                    crs,
202                })
203            }
204            _ => {
205                return Err(DetectionError::InvalidSchema(format!(
206                    "feature[{source_row}]: punctual geometry must be Point or null"
207                )))
208            }
209        },
210    };
211
212    let has_topo = netelement_id.is_some();
213    let has_coord = coordinates.is_some();
214    if has_topo && has_coord {
215        return Err(DetectionError::InvalidSchema(format!(
216            "feature[{source_row}]: cannot specify both 'netelement_id' and Point geometry"
217        )));
218    }
219    if !has_topo && !has_coord {
220        return Err(DetectionError::InvalidSchema(format!(
221            "feature[{source_row}]: must specify either 'netelement_id' or Point geometry"
222        )));
223    }
224
225    let location = netelement_id.map(|id| TopologicalLocation {
226        netelement_id: id,
227        intrinsic: intrinsic_value.unwrap_or(0.5),
228    });
229
230    Ok(Detection::Punctual(PunctualDetection {
231        timestamp,
232        location,
233        coordinates,
234        intrinsic: intrinsic_value,
235        id: opt_str(props, "id"),
236        source: opt_str(props, "source"),
237        source_file: source_file.to_string(),
238        source_row,
239        metadata: collect_metadata(props, PUNCTUAL_RESERVED),
240    }))
241}
242
243fn parse_linear(
244    props: &JsonMap<String, Json>,
245    source_file: &str,
246    source_row: usize,
247) -> Result<Detection, DetectionError> {
248    let t_from = parse_ts(
249        &require_str(props, "t_from", source_file, source_row)?,
250        source_file,
251        source_row,
252    )?;
253    let t_to = parse_ts(
254        &require_str(props, "t_to", source_file, source_row)?,
255        source_file,
256        source_row,
257    )?;
258    let netelement_id = require_str(props, "netelement_id", source_file, source_row)?;
259    let start_intrinsic =
260        opt_intrinsic(props, "start_intrinsic", source_file, source_row)?.unwrap_or(0.0);
261    let end_intrinsic =
262        opt_intrinsic(props, "end_intrinsic", source_file, source_row)?.unwrap_or(1.0);
263
264    Ok(Detection::Linear(LinearDetection {
265        t_from,
266        t_to,
267        netelement_id,
268        start_intrinsic,
269        end_intrinsic,
270        id: opt_str(props, "id"),
271        source: opt_str(props, "source"),
272        source_file: source_file.to_string(),
273        source_row,
274        metadata: collect_metadata(props, LINEAR_RESERVED),
275    }))
276}