sumo-types 0.1.0

Reads SUMO (Eclipse SUMO) .net.xml and .rou.xml files into 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::routes::domain`]).
//!
//! Mirrors `net`'s own `schema_mapper` in spirit — parsing the few fields
//! SUMO only restricts with a text pattern or union, translating enums —
//! but is independent code: `routes` doesn't require `net` to be enabled,
//! so it can't reuse `net`'s conversions even for a shared SUMO primitive
//! like `boolType` would be (`colorType` and `departType`, the ones this
//! module actually needs, aren't used by `net` at all).

use super::domain::{
    Color, Depart, EdgeRef, NamedColor, Route, RouteId, Routes, Vehicle, VehicleId, VehicleType,
    VehicleTypeId,
};
use crate::schema;
use anyhow::{Context, Result, bail};
use uom::si::f64::{Length, Time, Velocity};
use uom::si::length::meter;
use uom::si::time::second;
use uom::si::velocity::meter_per_second;

/// `routeType/@edges` is a whitespace-separated list of edge ids, same
/// convention as `net`'s (see `crate::net::schema_mapper::split_ids`) —
/// duplicated rather than shared for the same reason the two formats' id
/// newtypes are duplicated: `routes` doesn't depend on `net`.
fn split_edge_refs(raw: &str) -> Vec<EdgeRef> {
    raw.split_whitespace()
        .map(|s| EdgeRef(s.to_owned()))
        .collect()
}

/// SUMO accepts both `"grey"` and `"gray"` for the same named color.
impl From<schema::ColorType6Type> for NamedColor {
    fn from(value: schema::ColorType6Type) -> Self {
        use schema::ColorType6Type as S;
        match value {
            S::red => NamedColor::Red,
            S::green => NamedColor::Green,
            S::blue => NamedColor::Blue,
            S::yellow => NamedColor::Yellow,
            S::cyan => NamedColor::Cyan,
            S::magenta => NamedColor::Magenta,
            S::orange => NamedColor::Orange,
            S::white => NamedColor::White,
            S::black => NamedColor::Black,
            S::grey | S::gray => NamedColor::Grey,
            S::invisible => NamedColor::Invisible,
            S::random => NamedColor::Random,
        }
    }
}

/// Parses `colorType`'s numeric form: `"r,g,b"` or `"r,g,b,a"`, each
/// `0.0..=1.0` (`a` defaults to `1.0` when omitted).
impl TryFrom<&str> for Color {
    type Error = anyhow::Error;

    fn try_from(raw: &str) -> Result<Self> {
        let parts = raw
            .split(',')
            .map(|part| {
                part.parse::<f64>()
                    .with_context(|| format!("invalid SUMO color: {raw:?}"))
            })
            .collect::<Result<Vec<_>>>()?;

        match *parts.as_slice() {
            [r, g, b] => Ok(Color::Rgba { r, g, b, a: 1.0 }),
            [r, g, b, a] => Ok(Color::Rgba { r, g, b, a }),
            _ => bail!("SUMO color must have 3 or 4 components: {raw:?}"),
        }
    }
}

impl TryFrom<schema::ColorType> for Color {
    type Error = anyhow::Error;

    fn try_from(value: schema::ColorType) -> Result<Self> {
        match value {
            schema::ColorType::ColorType4(raw) => Color::try_from(raw.as_str()),
            schema::ColorType::ColorType6(named) => Ok(Color::Named(NamedColor::from(named))),
        }
    }
}

/// Parses `timeType`'s text-pattern union member: `"H:M:S"` or
/// `"H:M:S:MS"`.
fn parse_clock_time(raw: &str) -> Result<Time> {
    let parts = raw
        .split(':')
        .map(|part| {
            part.parse::<f64>()
                .with_context(|| format!("invalid SUMO clock time: {raw:?}"))
        })
        .collect::<Result<Vec<_>>>()?;

    let seconds = match *parts.as_slice() {
        [h, m, s] => h * 3600.0 + m * 60.0 + s,
        [h, m, s, ms] => h * 3600.0 + m * 60.0 + s + ms / 1000.0,
        _ => bail!("SUMO clock time must have 3 or 4 components: {raw:?}"),
    };
    Ok(Time::new::<second>(seconds))
}

/// `schema::SumoTimeType` is `types/base.xsd`'s `timeType`, renamed at
/// build time (see `RENAMED_TYPES` in `build.rs`) to avoid colliding with
/// xsd-parser's generated `TimeType` primitive.
impl TryFrom<schema::SumoTimeType> for Time {
    type Error = anyhow::Error;

    fn try_from(value: schema::SumoTimeType) -> Result<Self> {
        match value {
            schema::SumoTimeType::nonNegativeFloatType(seconds) => {
                Ok(Time::new::<second>(f64::from(seconds)))
            }
            schema::SumoTimeType::SumoTimeType9(raw) => parse_clock_time(&raw),
        }
    }
}

impl TryFrom<schema::DepartType> for Depart {
    type Error = anyhow::Error;

    fn try_from(value: schema::DepartType) -> Result<Self> {
        match value {
            schema::DepartType::sumoTimeType(time) => Ok(Depart::Time(Time::try_from(time)?)),
            schema::DepartType::DepartType13(trigger) => {
                use schema::DepartType13Type as S;
                Ok(match trigger {
                    S::triggered => Depart::Triggered,
                    S::containerTriggered => Depart::ContainerTriggered,
                    S::split => Depart::Split,
                    S::begin => Depart::Begin,
                })
            }
        }
    }
}

impl TryFrom<schema::RouteType> for Route {
    type Error = anyhow::Error;

    fn try_from(value: schema::RouteType) -> Result<Self> {
        Ok(Route {
            id: RouteId(value.id),
            edges: value.edges.as_deref().map(split_edge_refs),
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

impl TryFrom<schema::VTypeType> for VehicleType {
    type Error = anyhow::Error;

    fn try_from(value: schema::VTypeType) -> Result<Self> {
        Ok(VehicleType {
            id: VehicleTypeId(value.id),
            vehicle_class: value.v_class,
            length: value.length.map(|l| Length::new::<meter>(f64::from(l))),
            max_speed: value
                .max_speed
                .map(|s| Velocity::new::<meter_per_second>(f64::from(s))),
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

/// Converts SUMO's `vehicle` element (`schema::VehicleType`, an actual
/// vehicle instance) into [`Vehicle`] — not to be confused with
/// [`VehicleType`] (this crate's mapping of SUMO's `vType`/`vTypeType`, a
/// vehicle *class* definition). The naming clash is SUMO's own.
impl TryFrom<schema::VehicleType> for Vehicle {
    type Error = anyhow::Error;

    fn try_from(value: schema::VehicleType) -> Result<Self> {
        Ok(Vehicle {
            id: VehicleId(value.id),
            vehicle_type: value.type_.map(VehicleTypeId),
            route: value.route.map(RouteId),
            depart: Depart::try_from(value.depart)?,
            color: value.color.map(Color::try_from).transpose()?,
        })
    }
}

impl TryFrom<schema::RoutesType> for Routes {
    type Error = anyhow::Error;

    fn try_from(value: schema::RoutesType) -> Result<Self> {
        let mut routes = Routes::default();

        for item in value.content {
            match item {
                schema::RoutesTypeContent::vType(Some(v_type)) => {
                    routes.vehicle_types.push(VehicleType::try_from(v_type)?);
                }
                schema::RoutesTypeContent::route(Some(route)) => {
                    routes.routes.push(Route::try_from(route)?);
                }
                schema::RoutesTypeContent::vehicle(Some(vehicle)) => {
                    routes.vehicles.push(Vehicle::try_from(vehicle)?);
                }
                // Every other `routesType` element kind (`flow`, `trip`,
                // `person`, `personFlow`, `container`, `containerFlow`,
                // `interval`, `include`, `vTypeDistribution`,
                // `routeDistribution`), and any of the 3 above that showed
                // up as `None` (shouldn't happen per the XSD, but xsd-parser
                // models each choice branch as optional) — not modeled yet,
                // see the `domain` module docs.
                _ => {}
            }
        }

        Ok(routes)
    }
}

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

    #[test]
    fn parses_numeric_rgb_color_defaulting_alpha() {
        let color = Color::try_from("0.8,0.1,0.1").unwrap();
        assert_eq!(
            color,
            Color::Rgba {
                r: 0.8,
                g: 0.1,
                b: 0.1,
                a: 1.0
            }
        );
    }

    #[test]
    fn parses_numeric_rgba_color() {
        let color = Color::try_from("0.8,0.1,0.1,0.5").unwrap();
        assert_eq!(
            color,
            Color::Rgba {
                r: 0.8,
                g: 0.1,
                b: 0.1,
                a: 0.5
            }
        );
    }

    #[test]
    fn rejects_malformed_color() {
        assert!(Color::try_from("not-a-color").is_err());
        assert!(Color::try_from("1,2").is_err());
    }

    #[test]
    fn maps_both_spellings_of_grey_to_the_same_named_color() {
        assert_eq!(
            NamedColor::from(schema::ColorType6Type::grey),
            NamedColor::Grey
        );
        assert_eq!(
            NamedColor::from(schema::ColorType6Type::gray),
            NamedColor::Grey
        );
    }

    #[test]
    fn parses_clock_time_without_milliseconds() {
        use uom::si::time::second;
        let time = parse_clock_time("1:02:03").unwrap();
        assert_eq!(time.get::<second>(), 3723.0);
    }

    #[test]
    fn parses_clock_time_with_milliseconds() {
        use uom::si::time::second;
        let time = parse_clock_time("0:00:01:500").unwrap();
        assert_eq!(time.get::<second>(), 1.5);
    }

    #[test]
    fn rejects_malformed_clock_time() {
        assert!(parse_clock_time("not-a-time").is_err());
        assert!(parse_clock_time("1:02").is_err());
    }

    #[test]
    fn splits_whitespace_separated_edge_list() {
        assert_eq!(
            split_edge_refs("e0 e1 e2"),
            vec![
                EdgeRef("e0".into()),
                EdgeRef("e1".into()),
                EdgeRef("e2".into())
            ]
        );
    }
}