sumo-types 0.2.1

Reads and writes SUMO (Eclipse SUMO) .net.xml, .rou.xml and .add.xml files as well-typed Rust structs
Documentation
//! Conversion from layer 1 (the private `schema` module, generated by
//! xsd-parser from the SUMO XSDs) to layer 2
//! ([`crate::additional::domain`]).
//!
//! Mirrors the other formats' mappers in spirit, but is independent code:
//! `additional` doesn't require `net` or `routes` to be enabled, so it
//! can't reuse their conversions.
//!
//! The one thing worth knowing here is that SUMO's `floatType` is declared
//! in `types/base.xsd` as an `xsd:string` restricted by a pattern, not as
//! `xsd:float` — so xsd-parser hands positions over as `String` and this
//! module parses them. `positiveFloatType` and `nonNegativeFloatType` *are*
//! real `xsd:float`s, and arrive as `f32`.

use super::domain::{
    Additional, DetectorGate, DetectorId, E1Detector, E2Detector, E3Detector, LaneCoverage,
    LanePosition, LaneRef, Point,
};
use crate::schema;
use crate::sumo::{parse_bool_opt, parse_finite, split_ids};
use crate::{Error, Result};
use uom::si::f64::{Length, Time, Velocity};
use uom::si::length::meter;
use uom::si::time::second;
use uom::si::velocity::meter_per_second;

/// SUMO packs "from the start" and "from the end" into the sign of one
/// number; [`LanePosition`] splits them, so the sign is read here and
/// nowhere else.
fn lane_position(raw: &str, context: &'static str) -> Result<LanePosition> {
    let value = parse_finite(raw, context)?;

    Ok(if value < 0.0 {
        LanePosition::FromEnd(Length::new::<meter>(-value))
    } else {
        LanePosition::FromStart(Length::new::<meter>(value))
    })
}

/// Parses `e3Detector/@pos`, the icon position: `"x,y"` or `"x,y,z"`. Same
/// encoding as every other SUMO position even though the XSD types this one
/// attribute as a bare `xsd:string`; only the error context differs, which
/// is what [`Point::parse`] takes over `Point`'s `TryFrom<&str>`.
fn icon_position(raw: &str) -> Result<Point> {
    Point::parse(raw, "detector icon position")
}

fn seconds(value: Option<f32>) -> Option<Time> {
    value.map(|s| Time::new::<second>(f64::from(s)))
}

fn meters(value: Option<f32>) -> Option<Length> {
    value.map(|m| Length::new::<meter>(f64::from(m)))
}

/// SUMO's `period` supersedes the deprecated `freq`; both mean the same
/// sampling interval, so fall back to `freq` when only it is present.
fn sampling_period(period: Option<f32>, freq: Option<f32>) -> Option<Time> {
    seconds(period.or(freq))
}

impl TryFrom<schema::DetEntryExitType> for DetectorGate {
    type Error = Error;

    fn try_from(value: schema::DetEntryExitType) -> Result<Self> {
        Ok(DetectorGate {
            lane: LaneRef(value.lane),
            position: lane_position(&value.pos, "detector gate position")?,
            friendly_position: parse_bool_opt(value.friendly_pos)?,
        })
    }
}

impl TryFrom<schema::E1DetectorType> for E1Detector {
    type Error = Error;

    fn try_from(value: schema::E1DetectorType) -> Result<Self> {
        Ok(E1Detector {
            id: DetectorId(value.id),
            lane: LaneRef(value.lane),
            position: lane_position(&value.pos, "e1Detector position")?,
            file: value.file,
            period: sampling_period(value.period, value.freq),
            length: meters(value.length),
            name: value.name,
            friendly_position: parse_bool_opt(value.friendly_pos)?,
        })
    }
}

/// Resolves `e2Detector`'s `lane` / `lanes` / `length` trio, rejecting the
/// two combinations SUMO itself rejects.
///
/// Per SUMO's documentation for `lanes`: *"This argument excludes the
/// arguments lane and length."* So `lanes` is exclusive with both of the
/// others, while `lane` + `length` is the normal single-anchor form.
///
/// This is input validation, not an invariant of [`LaneCoverage`]: a
/// malformed document should fail here, at the parse boundary, the same way
/// a `NaN` position or a wrong root element does. Folding `length` into the
/// `SingleLane` variant to make the bad state unrepresentable was
/// considered and rejected — `length` can legally appear with neither
/// attribute set, and it would have had nowhere to live, turning a visible
/// error into silent data loss.
fn lane_coverage(
    lane: Option<String>,
    lanes: Option<&str>,
    length: Option<&str>,
) -> Result<Option<LaneCoverage>> {
    match (lane, lanes) {
        (Some(_), Some(_)) => Err(Error::ConflictingAttributes {
            element: "e2Detector",
            first: "lane",
            second: "lanes",
        }),
        (_, Some(_)) if length.is_some() => Err(Error::ConflictingAttributes {
            element: "e2Detector",
            first: "lanes",
            second: "length",
        }),
        (Some(lane), None) => Ok(Some(LaneCoverage::SingleLane(LaneRef(lane)))),
        (None, Some(lanes)) => Ok(Some(LaneCoverage::LaneChain(split_ids(lanes)))),
        (None, None) => Ok(None),
    }
}

impl TryFrom<schema::E2DetectorType> for E2Detector {
    type Error = Error;

    fn try_from(value: schema::E2DetectorType) -> Result<Self> {
        Ok(E2Detector {
            id: DetectorId(value.id),
            coverage: lane_coverage(value.lane, value.lanes.as_deref(), value.length.as_deref())?,
            position: value
                .pos
                .as_deref()
                .map(|pos| lane_position(pos, "e2Detector position"))
                .transpose()?,
            end_position: value
                .end_pos
                .as_deref()
                .map(|pos| lane_position(pos, "e2Detector end position"))
                .transpose()?,
            length: value
                .length
                .as_deref()
                .map(|raw| parse_finite(raw, "e2Detector length"))
                .transpose()?
                .map(Length::new::<meter>),
            file: value.file,
            period: sampling_period(value.period, value.freq),
            name: value.name,
            friendly_position: parse_bool_opt(value.friendly_pos)?,
            speed_threshold: value
                .speed_threshold
                .as_deref()
                .map(|raw| parse_finite(raw, "e2Detector speedThreshold"))
                .transpose()?
                .map(Velocity::new::<meter_per_second>),
            time_threshold: value
                .time_threshold
                .as_deref()
                .map(|raw| parse_finite(raw, "e2Detector timeThreshold"))
                .transpose()?
                .map(Time::new::<second>),
            jam_threshold: value
                .jam_threshold
                .as_deref()
                .map(|raw| parse_finite(raw, "e2Detector jamThreshold"))
                .transpose()?
                .map(Length::new::<meter>),
        })
    }
}

impl TryFrom<schema::E3DetectorType> for E3Detector {
    type Error = Error;

    fn try_from(value: schema::E3DetectorType) -> Result<Self> {
        let mut entries = Vec::new();
        let mut exits = Vec::new();

        for item in value.content.det_gate_group {
            match item.content {
                schema::E3DetectorDetGateGroupTypeContent::detEntry(gates) => {
                    for gate in gates {
                        entries.push(DetectorGate::try_from(gate)?);
                    }
                }
                schema::E3DetectorDetGateGroupTypeContent::detExit(gates) => {
                    for gate in gates {
                        exits.push(DetectorGate::try_from(gate)?);
                    }
                }
            }
        }

        Ok(E3Detector {
            id: DetectorId(value.id),
            entries,
            exits,
            file: value.file,
            period: sampling_period(value.period, value.freq),
            name: value.name,
            icon_position: value.pos.as_deref().map(icon_position).transpose()?,
            speed_threshold: value
                .speed_threshold
                .map(|s| Velocity::new::<meter_per_second>(f64::from(s))),
            time_threshold: value
                .time_threshold
                .as_deref()
                .map(|raw| parse_finite(raw, "e3Detector timeThreshold"))
                .transpose()?
                .map(Time::new::<second>),
            open_entry: parse_bool_opt(value.open_entry)?,
        })
    }
}

impl TryFrom<schema::AdditionalType> for Additional {
    type Error = Error;

    fn try_from(value: schema::AdditionalType) -> Result<Self> {
        let mut additional = Additional::default();

        for item in value.content {
            match item {
                // `inductionLoop`, `laneAreaDetector` and `entryExitDetector`
                // are SUMO's alternative spellings of `e1Detector`,
                // `e2Detector` and `e3Detector`: the same XSD type under a
                // second element name, so both map to the same domain type.
                schema::AdditionalTypeContent::e1Detector(Some(detector))
                | schema::AdditionalTypeContent::inductionLoop(Some(detector)) => {
                    additional
                        .induction_loops
                        .push(E1Detector::try_from(detector)?);
                }
                schema::AdditionalTypeContent::e2Detector(Some(detector))
                | schema::AdditionalTypeContent::laneAreaDetector(Some(detector)) => {
                    additional
                        .lane_area_detectors
                        .push(E2Detector::try_from(detector)?);
                }
                schema::AdditionalTypeContent::e3Detector(Some(detector))
                | schema::AdditionalTypeContent::entryExitDetector(Some(detector)) => {
                    additional
                        .entry_exit_detectors
                        .push(E3Detector::try_from(detector)?);
                }
                // Every other `additionalType` element kind (`busStop`,
                // `rerouter`, `calibrator`, `parkingArea`, `poly`, the whole
                // of `routesType` again, ...), and any of the above that
                // showed up as `None` (shouldn't happen per the XSD, but
                // xsd-parser models each choice branch as optional) — not
                // modelled, see the `domain` module docs.
                _ => {}
            }
        }

        Ok(additional)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reads_the_sign_of_a_position_as_which_end_it_is_measured_from() {
        assert_eq!(
            lane_position("12.5", "test").unwrap(),
            LanePosition::FromStart(Length::new::<meter>(12.5))
        );
        // Negative means "back from the lane's end", and the variant says
        // so instead of leaving a signed number for the caller to misread.
        assert_eq!(
            lane_position("-12.5", "test").unwrap(),
            LanePosition::FromEnd(Length::new::<meter>(12.5))
        );
    }

    #[test]
    fn rejects_the_lane_combinations_sumo_rejects() {
        // `lanes` excludes both `lane` and `length`.
        assert!(lane_coverage(Some("e0_0".into()), Some("e0_0 e1_0"), None).is_err());
        assert!(lane_coverage(None, Some("e0_0 e1_0"), Some("50.0")).is_err());

        // `lane` + `length` is the normal single-anchor form, and `length`
        // with neither attribute is odd but not something SUMO forbids.
        assert_eq!(
            lane_coverage(Some("e0_0".into()), None, Some("50.0")).unwrap(),
            Some(LaneCoverage::SingleLane(LaneRef("e0_0".into())))
        );
        assert_eq!(lane_coverage(None, None, Some("50.0")).unwrap(), None);
        assert_eq!(lane_coverage(None, None, None).unwrap(), None);
    }

    #[test]
    fn parses_the_icon_position_with_and_without_z() {
        assert_eq!(
            icon_position("42,7").unwrap(),
            Point {
                x: 42.0,
                y: 7.0,
                z: 0.0
            }
        );
        assert_eq!(
            icon_position("42,7,1.5").unwrap(),
            Point {
                x: 42.0,
                y: 7.0,
                z: 1.5
            }
        );
        assert!(icon_position("42").is_err());
        assert!(icon_position("42,7,1,9").is_err());
    }

    #[test]
    fn falls_back_from_period_to_the_deprecated_freq() {
        use uom::si::time::second;

        assert_eq!(
            sampling_period(Some(60.0), None).unwrap().get::<second>(),
            60.0
        );
        assert_eq!(
            sampling_period(None, Some(30.0)).unwrap().get::<second>(),
            30.0
        );
        // `period` wins when SUMO wrote both.
        assert_eq!(
            sampling_period(Some(60.0), Some(30.0))
                .unwrap()
                .get::<second>(),
            60.0
        );
        assert!(sampling_period(None, None).is_none());
    }

    #[test]
    fn splits_a_lane_chain() {
        assert_eq!(
            split_ids::<LaneRef>("e0_0 e1_0  e2_0"),
            vec![
                LaneRef("e0_0".into()),
                LaneRef("e1_0".into()),
                LaneRef("e2_0".into())
            ]
        );
    }

    #[test]
    fn rejects_bool_without_binary_meaning() {
        assert!(parse_bool_opt(Some(schema::BoolType::x)).is_err());
        assert_eq!(parse_bool_opt(None).unwrap(), None);
        assert_eq!(
            parse_bool_opt(Some(schema::BoolType::True)).unwrap(),
            Some(true)
        );
    }
}