use crate::errors::ProjectionError;
use crate::models::{GnssPosition, NetRelation, Netelement, ProjectedPosition};
use crate::temporal::parse_timestamp_flexible;
use geo::{Coord, LineString};
use geojson::{Feature, GeoJson, Value};
use std::fs;
const DEFAULT_CRS: &str = "EPSG:4326";
fn parse_crs_from_feature_collection(feature_collection: &geojson::FeatureCollection) -> String {
let Some(crs_obj) = &feature_collection.foreign_members else {
return DEFAULT_CRS.to_string();
};
let Some(crs_value) = crs_obj.get("crs") else {
return DEFAULT_CRS.to_string();
};
crs_value
.get("properties")
.and_then(|props| props.get("name"))
.and_then(|name| name.as_str())
.and_then(|name_str| {
if name_str.contains("EPSG") {
name_str
.split("::")
.last()
.map(|code| format!("EPSG:{}", code))
} else {
None
}
})
.unwrap_or_else(|| DEFAULT_CRS.to_string())
}
pub fn parse_network_geojson(
path: &str,
) -> Result<(Vec<Netelement>, Vec<NetRelation>), ProjectionError> {
let geojson_str = fs::read_to_string(path)?;
parse_network_geojson_str(&geojson_str)
}
pub fn parse_network_geojson_str(
geojson_str: &str,
) -> Result<(Vec<Netelement>, Vec<NetRelation>), ProjectionError> {
let geojson = geojson_str
.parse::<GeoJson>()
.map_err(|e| ProjectionError::InvalidGeometry(format!("Failed to parse GeoJSON: {}", e)))?;
let feature_collection = match geojson {
GeoJson::FeatureCollection(fc) => fc,
_ => {
return Err(ProjectionError::InvalidGeometry(
"GeoJSON must be a FeatureCollection".to_string(),
))
}
};
let crs = parse_crs_from_feature_collection(&feature_collection);
if !crs.contains("4326") && !crs.contains("WGS84") {
return Err(ProjectionError::InvalidCrs(format!(
"GeoJSON CRS must be WGS84 (EPSG:4326) per RFC 7946, got: {}",
crs
)));
}
let mut netelements = Vec::new();
let mut netrelations = Vec::new();
for (idx, feature) in feature_collection.features.iter().enumerate() {
if let Some(props) = &feature.properties {
if let Some(feature_type) = props.get("type") {
if feature_type.as_str() == Some("netrelation") {
let netrelation = parse_netrelation_feature(feature, idx)?;
netrelations.push(netrelation);
continue;
}
}
}
let netelement = parse_feature(feature, &crs, idx)?;
netelements.push(netelement);
}
if netelements.is_empty() {
return Err(ProjectionError::EmptyNetwork);
}
Ok((netelements, netrelations))
}
pub fn parse_gnss_geojson(path: &str, crs: &str) -> Result<Vec<GnssPosition>, ProjectionError> {
let geojson_str = fs::read_to_string(path)?;
parse_gnss_geojson_str(&geojson_str, crs)
}
pub fn parse_gnss_geojson_str(
geojson_str: &str,
crs: &str,
) -> Result<Vec<GnssPosition>, ProjectionError> {
let geojson = geojson_str
.parse::<GeoJson>()
.map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
let feature_collection = match geojson {
GeoJson::FeatureCollection(fc) => fc,
_ => {
return Err(ProjectionError::GeoJsonError(
"GeoJSON must be a FeatureCollection".to_string(),
))
}
};
let mut positions = Vec::new();
for (idx, feature) in feature_collection.features.iter().enumerate() {
let position = parse_gnss_feature(feature, crs, idx)?;
positions.push(position);
}
if positions.is_empty() {
return Err(ProjectionError::GeoJsonError(
"GeoJSON contains no valid GNSS positions".to_string(),
));
}
Ok(positions)
}
fn parse_gnss_feature(
feature: &Feature,
crs: &str,
idx: usize,
) -> Result<GnssPosition, ProjectionError> {
let geometry = feature.geometry.as_ref().ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Feature {} missing geometry", idx))
})?;
let (longitude, latitude) = match &geometry.value {
Value::Point(coords) => {
if coords.len() < 2 {
return Err(ProjectionError::InvalidCoordinate(format!(
"Feature {} Point must have at least 2 coordinates",
idx
)));
}
(coords[0], coords[1])
}
_ => {
return Err(ProjectionError::GeoJsonError(format!(
"Feature {} must have Point geometry for GNSS position",
idx
)))
}
};
if !(-90.0..=90.0).contains(&latitude) {
return Err(ProjectionError::InvalidCoordinate(format!(
"Feature {}: latitude {} out of range [-90, 90]",
idx, latitude
)));
}
if !(-180.0..=180.0).contains(&longitude) {
return Err(ProjectionError::InvalidCoordinate(format!(
"Feature {}: longitude {} out of range [-180, 180]",
idx, longitude
)));
}
let properties = feature.properties.as_ref().ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Feature {} missing properties (timestamp required)",
idx
))
})?;
let timestamp_str = properties
.get("timestamp")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProjectionError::MissingTimezone(format!(
"Feature {} missing 'timestamp' property",
idx
))
})?;
let timestamp = parse_timestamp_flexible(timestamp_str).map_err(|e| {
ProjectionError::InvalidTimestamp(format!(
"Feature {}: invalid timestamp '{}': {}",
idx, timestamp_str, e
))
})?;
let mut metadata = std::collections::HashMap::new();
let mut heading: Option<f64> = None;
let mut distance: Option<f64> = None;
for (key, value) in properties {
match key.as_str() {
"timestamp" => {} "heading" => {
if let Some(h) = value.as_f64() {
if (0.0..=360.0).contains(&h) {
heading = Some(h);
} else {
return Err(ProjectionError::InvalidGeometry(format!(
"Feature {}: heading {} not in [0, 360]",
idx, h
)));
}
}
}
"distance" => {
if let Some(d) = value.as_f64() {
if d >= 0.0 {
distance = Some(d);
} else {
return Err(ProjectionError::InvalidGeometry(format!(
"Feature {}: distance {} must be >= 0",
idx, d
)));
}
}
}
_ => {
if let Some(str_value) = value.as_str() {
metadata.insert(key.clone(), str_value.to_string());
} else {
metadata.insert(key.clone(), value.to_string());
}
}
}
}
Ok(GnssPosition {
latitude,
longitude,
timestamp,
crs: crs.to_string(),
metadata,
heading,
distance,
})
}
fn parse_feature(feature: &Feature, crs: &str, idx: usize) -> Result<Netelement, ProjectionError> {
let geometry = feature.geometry.as_ref().ok_or_else(|| {
ProjectionError::InvalidGeometry(format!("Feature {} missing geometry", idx))
})?;
let linestring = match &geometry.value {
Value::LineString(coords) => {
let geo_coords: Vec<Coord<f64>> = coords
.iter()
.map(|pos| Coord {
x: pos[0],
y: pos[1],
})
.collect();
LineString::from(geo_coords)
}
Value::MultiLineString(lines) => {
if lines.is_empty() {
return Err(ProjectionError::InvalidGeometry(format!(
"Feature {} has empty MultiLineString",
idx
)));
}
let geo_coords: Vec<Coord<f64>> = lines[0]
.iter()
.map(|pos| Coord {
x: pos[0],
y: pos[1],
})
.collect();
LineString::from(geo_coords)
}
_ => {
return Err(ProjectionError::InvalidGeometry(format!(
"Feature {} must have LineString or MultiLineString geometry",
idx
)))
}
};
let id = if let Some(props) = &feature.properties {
if let Some(id_value) = props.get("id") {
id_value
.as_str()
.map(|s| s.to_string())
.or_else(|| id_value.as_i64().map(|i| i.to_string()))
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Feature {} has invalid 'id' property type",
idx
))
})?
} else {
return Err(ProjectionError::GeoJsonError(format!(
"Feature {} missing required 'id' property",
idx
)));
}
} else {
return Err(ProjectionError::GeoJsonError(format!(
"Feature {} missing properties object",
idx
)));
};
Netelement::new(id, linestring, crs.to_string())
}
pub fn parse_netrelations_geojson(path: &str) -> Result<Vec<NetRelation>, ProjectionError> {
let geojson_str = fs::read_to_string(path)?;
let geojson = geojson_str
.parse::<GeoJson>()
.map_err(|e| ProjectionError::GeoJsonError(format!("Failed to parse GeoJSON: {}", e)))?;
let feature_collection = match geojson {
GeoJson::FeatureCollection(fc) => fc,
_ => {
return Err(ProjectionError::GeoJsonError(
"GeoJSON must be a FeatureCollection".to_string(),
))
}
};
let mut netrelations = Vec::new();
for (idx, feature) in feature_collection.features.iter().enumerate() {
if let Some(props) = &feature.properties {
if let Some(feature_type) = props.get("type") {
if feature_type.as_str() == Some("netrelation") {
let netrelation = parse_netrelation_feature(feature, idx)?;
netrelations.push(netrelation);
}
}
}
}
Ok(netrelations)
}
fn parse_netrelation_feature(
feature: &Feature,
idx: usize,
) -> Result<NetRelation, ProjectionError> {
let properties = feature.properties.as_ref().ok_or_else(|| {
ProjectionError::GeoJsonError(format!("Netrelation feature {} missing properties", idx))
})?;
let id = properties
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing 'id' property",
idx
))
})?
.to_string();
let netelement_a = properties
.get("netelementA")
.or_else(|| properties.get("from"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing 'netelementA' or 'from' property",
idx
))
})?
.to_string();
let netelement_b = properties
.get("netelementB")
.or_else(|| properties.get("to"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing 'netelementB' or 'to' property",
idx
))
})?
.to_string();
let position_on_a = properties
.get("positionOnA")
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing or invalid 'positionOnA' property",
idx
))
})? as u8;
let position_on_b = properties
.get("positionOnB")
.and_then(|v| v.as_u64().or_else(|| v.as_f64().map(|f| f as u64)))
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing or invalid 'positionOnB' property",
idx
))
})? as u8;
let navigability_str = properties
.get("navigability")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"Netrelation feature {} missing 'navigability' property",
idx
))
})?;
let (navigable_forward, navigable_backward) = match navigability_str.to_lowercase().as_str() {
"both" => (true, true),
"ab" => (true, false),
"ba" => (false, true),
"none" => (false, false),
_ => return Err(ProjectionError::GeoJsonError(
format!("Netrelation feature {}: invalid navigability value '{}' (expected: both, AB, BA, or none)", idx, navigability_str)
)),
};
let netrelation = NetRelation::new(
id,
netelement_a,
netelement_b,
position_on_a,
position_on_b,
navigable_forward,
navigable_backward,
)?;
Ok(netrelation)
}
pub fn write_network_geojson(
netelements: &[Netelement],
netrelations: &[NetRelation],
writer: &mut impl std::io::Write,
) -> Result<(), ProjectionError> {
use geojson::{Feature, FeatureCollection, Geometry, Value};
use serde_json::{Map, Value as JsonValue};
let mut features: Vec<Feature> = Vec::with_capacity(netelements.len() + netrelations.len());
for ne in netelements {
let coords: Vec<Vec<f64>> = ne.geometry.coords().map(|c| vec![c.x, c.y]).collect();
let geometry = Geometry::new(Value::LineString(coords));
let mut properties = Map::new();
properties.insert("id".to_string(), JsonValue::from(ne.id.clone()));
properties.insert("crs".to_string(), JsonValue::from(ne.crs.clone()));
features.push(Feature {
bbox: None,
geometry: Some(geometry),
id: None,
properties: Some(properties),
foreign_members: None,
});
}
for nr in netrelations {
let navigability = match (nr.navigable_forward, nr.navigable_backward) {
(true, true) => "both",
(true, false) => "AB",
(false, true) => "BA",
(false, false) => "none",
};
let mut properties = Map::new();
properties.insert("type".to_string(), JsonValue::from("netrelation"));
properties.insert("id".to_string(), JsonValue::from(nr.id.clone()));
properties.insert(
"netelementA".to_string(),
JsonValue::from(nr.from_netelement_id.clone()),
);
properties.insert(
"netelementB".to_string(),
JsonValue::from(nr.to_netelement_id.clone()),
);
properties.insert(
"positionOnA".to_string(),
JsonValue::from(nr.position_on_a as u64),
);
properties.insert(
"positionOnB".to_string(),
JsonValue::from(nr.position_on_b as u64),
);
properties.insert("navigability".to_string(), JsonValue::from(navigability));
features.push(Feature {
bbox: None,
geometry: None,
id: None,
properties: Some(properties),
foreign_members: None,
});
}
let feature_collection = FeatureCollection {
bbox: None,
features,
foreign_members: None,
};
let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
ProjectionError::GeoJsonError(format!("Failed to serialize network GeoJSON: {}", e))
})?;
writer.write_all(json.as_bytes())?;
Ok(())
}
pub fn write_geojson(
positions: &[ProjectedPosition],
writer: &mut impl std::io::Write,
) -> Result<(), ProjectionError> {
use geojson::{Feature, FeatureCollection, Geometry, Value};
use serde_json::{Map, Value as JsonValue};
let mut features = Vec::new();
for pos in positions {
let geometry = Geometry::new(Value::Point(vec![
pos.projected_coords.x(),
pos.projected_coords.y(),
]));
let mut properties = Map::new();
properties.insert(
"original_lat".to_string(),
JsonValue::from(pos.original.latitude),
);
properties.insert(
"original_lon".to_string(),
JsonValue::from(pos.original.longitude),
);
properties.insert(
"original_time".to_string(),
JsonValue::from(pos.original.timestamp.to_rfc3339()),
);
properties.insert(
"netelement_id".to_string(),
JsonValue::from(pos.netelement_id.clone()),
);
properties.insert(
"measure_meters".to_string(),
JsonValue::from(pos.measure_meters),
);
properties.insert(
"projection_distance_meters".to_string(),
JsonValue::from(pos.projection_distance_meters),
);
properties.insert("crs".to_string(), JsonValue::from(pos.crs.clone()));
for (key, value) in &pos.original.metadata {
properties.insert(format!("original_{}", key), JsonValue::from(value.clone()));
}
let feature = Feature {
bbox: None,
geometry: Some(geometry),
id: None,
properties: Some(properties),
foreign_members: None,
};
features.push(feature);
}
let feature_collection = FeatureCollection {
bbox: None,
features,
foreign_members: None,
};
let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
ProjectionError::GeoJsonError(format!("Failed to serialize GeoJSON: {}", e))
})?;
writer.write_all(json.as_bytes())?;
Ok(())
}
pub fn parse_trainpath_geojson(path: &str) -> Result<crate::models::TrainPath, ProjectionError> {
use crate::models::AssociatedNetElement;
let geojson_str = fs::read_to_string(path)?;
let geojson = geojson_str.parse::<GeoJson>().map_err(|e| {
ProjectionError::GeoJsonError(format!("Failed to parse TrainPath GeoJSON: {}", e))
})?;
let fc = match geojson {
GeoJson::FeatureCollection(fc) => fc,
_ => {
return Err(ProjectionError::GeoJsonError(
"TrainPath GeoJSON must be a FeatureCollection".to_string(),
))
}
};
let (overall_probability, calculated_at) = fc
.foreign_members
.as_ref()
.and_then(|fm| fm.get("properties"))
.and_then(|v| v.as_object())
.map(|props| {
let prob = props.get("overall_probability").and_then(|v| v.as_f64());
let calc_at = props
.get("calculated_at")
.and_then(|v| v.as_str())
.and_then(|s| parse_timestamp_flexible(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc));
(prob, calc_at)
})
.unwrap_or((None, None));
let mut segments = Vec::new();
for (idx, feature) in fc.features.iter().enumerate() {
let props = feature.properties.as_ref().ok_or_else(|| {
ProjectionError::GeoJsonError(format!("TrainPath feature {} missing properties", idx))
})?;
macro_rules! get_str {
($key:expr) => {
props
.get($key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"TrainPath feature {} missing or invalid '{}' property",
idx, $key
))
})
};
}
macro_rules! get_f64 {
($key:expr) => {
props.get($key).and_then(|v| v.as_f64()).ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"TrainPath feature {} missing or invalid '{}' property",
idx, $key
))
})
};
}
macro_rules! get_usize {
($key:expr) => {
props
.get($key)
.and_then(|v| v.as_u64())
.map(|v| v as usize)
.ok_or_else(|| {
ProjectionError::GeoJsonError(format!(
"TrainPath feature {} missing or invalid '{}' property",
idx, $key
))
})
};
}
let segment = AssociatedNetElement::new(
get_str!("netelement_id")?,
get_f64!("probability")?,
get_f64!("start_intrinsic")?,
get_f64!("end_intrinsic")?,
get_usize!("gnss_start_index")?,
get_usize!("gnss_end_index")?,
)?;
segments.push(segment);
}
let overall_prob = overall_probability.unwrap_or_else(|| {
if segments.is_empty() {
1.0
} else {
let sum: f64 = segments.iter().map(|s| s.probability).sum();
sum / segments.len() as f64
}
});
crate::models::TrainPath::new(segments, overall_prob, calculated_at, None)
}
pub fn write_trainpath_geojson(
train_path: &crate::models::TrainPath,
netelements: &std::collections::HashMap<String, Netelement>,
writer: &mut impl std::io::Write,
) -> Result<(), ProjectionError> {
use geojson::{Feature, FeatureCollection, Geometry, Value};
use serde_json::{Map, Value as JsonValue};
let mut features = Vec::new();
for segment in &train_path.segments {
let netelement = netelements.get(&segment.netelement_id).ok_or_else(|| {
ProjectionError::InvalidGeometry(format!(
"Netelement {} not found in provided map",
segment.netelement_id
))
})?;
let coords: Vec<Vec<f64>> = netelement
.geometry
.points()
.map(|point| vec![point.x(), point.y()])
.collect();
let geometry = Geometry::new(Value::LineString(coords));
let mut properties = Map::new();
properties.insert(
"netelement_id".to_string(),
JsonValue::from(segment.netelement_id.clone()),
);
properties.insert(
"probability".to_string(),
JsonValue::from(segment.probability),
);
properties.insert(
"start_intrinsic".to_string(),
JsonValue::from(segment.start_intrinsic),
);
properties.insert(
"end_intrinsic".to_string(),
JsonValue::from(segment.end_intrinsic),
);
properties.insert(
"gnss_start_index".to_string(),
JsonValue::from(segment.gnss_start_index as i64),
);
properties.insert(
"gnss_end_index".to_string(),
JsonValue::from(segment.gnss_end_index as i64),
);
let feature = Feature {
bbox: None,
geometry: Some(geometry),
id: None,
properties: Some(properties),
foreign_members: None,
};
features.push(feature);
}
let mut fc_properties = Map::new();
fc_properties.insert(
"overall_probability".to_string(),
JsonValue::from(train_path.overall_probability),
);
if let Some(calculated_at) = &train_path.calculated_at {
fc_properties.insert(
"calculated_at".to_string(),
JsonValue::from(calculated_at.to_rfc3339()),
);
}
if let Some(metadata) = &train_path.metadata {
fc_properties.insert(
"distance_scale".to_string(),
JsonValue::from(metadata.distance_scale),
);
fc_properties.insert(
"heading_scale".to_string(),
JsonValue::from(metadata.heading_scale),
);
fc_properties.insert(
"cutoff_distance".to_string(),
JsonValue::from(metadata.cutoff_distance),
);
fc_properties.insert(
"heading_cutoff".to_string(),
JsonValue::from(metadata.heading_cutoff),
);
fc_properties.insert(
"probability_threshold".to_string(),
JsonValue::from(metadata.probability_threshold),
);
if let Some(resampling_dist) = metadata.resampling_distance {
fc_properties.insert(
"resampling_distance".to_string(),
JsonValue::from(resampling_dist),
);
}
fc_properties.insert(
"fallback_mode".to_string(),
JsonValue::from(metadata.fallback_mode),
);
fc_properties.insert(
"candidate_paths_evaluated".to_string(),
JsonValue::from(metadata.candidate_paths_evaluated as i64),
);
fc_properties.insert(
"bidirectional_path".to_string(),
JsonValue::from(metadata.bidirectional_path),
);
}
let mut foreign_members = Map::new();
foreign_members.insert("properties".to_string(), JsonValue::Object(fc_properties));
let feature_collection = FeatureCollection {
bbox: None,
features,
foreign_members: Some(foreign_members),
};
let json = serde_json::to_string_pretty(&feature_collection).map_err(|e| {
ProjectionError::GeoJsonError(format!("Failed to serialize TrainPath GeoJSON: {}", e))
})?;
writer.write_all(json.as_bytes())?;
Ok(())
}
#[cfg(test)]
mod tests;
pub mod detections;