sumo-types 0.2.2

Reads and writes SUMO (Eclipse SUMO) .net.xml, .rou.xml and .add.xml files as well-typed Rust structs
Documentation

sumo-types

Reads — and, with the write feature, writes — SUMO files as well-typed Rust structs.

At the time of writing there is no other Rust crate that does this: traci-rs speaks the TraCI protocol to a running simulation, and SUMO's own sumolib is Python. This crate fills the offline, file-reading gap.

$ cargo add sumo-types                            # `net` only (default)
$ cargo add sumo-types --features routes          # `net` + `routes`
$ cargo add sumo-types --features additional      # `net` + `additional`
$ cargo add sumo-types --features write           # `net` + writing it back out

Requires Rust 1.86 or newer.

use sumo_types::domain::JunctionKind;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let network = sumo_types::read_network(std::path::Path::new("city.net.xml"))?;

    for junction in &network.junctions {
        if junction.kind == JunctionKind::TrafficLight {
            println!("{} has {} incoming lanes", junction.id, junction.incoming_lanes.len());
        }
    }

    Ok(())
}

Formats

SUMO has one file format per Cargo feature. Three are implemented:

Feature Format Public API
net (default) .net.xml (netconvert output) sumo_types::domain, sumo_types::read_network
routes .rou.xml (traffic demand) sumo_types::routes
additional .add.xml (E1/E2/E3 detectors) sumo_types::additional

A fourth feature, write, isn't a format — it adds a write_* counterpart to every enabled format's read_* (sumo_types::write_network, sumo_types::routes::write_routes, sumo_types::additional::write_additional), so it does nothing on its own. See Writing.

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let routes = sumo_types::routes::read_routes(std::path::Path::new("city.rou.xml"))?;
    for vehicle in &routes.vehicles {
        println!("{} departs at {:?}", vehicle.id, vehicle.depart);
    }

    Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let additional = sumo_types::additional::read_additional(
        std::path::Path::new("city.add.xml"),
    )?;
    for detector in &additional.entry_exit_detectors {
        println!("{} has {} entries", detector.id, detector.entries.len());
    }

    Ok(())
}

All three examples above are compiled as doctests (see ReadmeExamples in src/lib.rs), so they can't drift from the real API.

net's API sits at the crate root (sumo_types::domain::Network, sumo_types::read_network) because it was this crate's first, only format, and that path is kept stable now. routes, added later, is namespaced at sumo_types::routes instead — both to avoid colliding with net's type names (a route's edges and a network's edges are both just ids, but domain::EdgeId and routes::domain::EdgeRef are unrelated Rust types) and so the path a type is imported from tells you which SUMO format it belongs to. Internally, net's files live under src/net/ and are re-exported at the crate root — a module's file location and its public path are independent in Rust, so nesting the files didn't have to mean nesting the public API too.

How it works

SUMO ships XML Schema (XSD) definitions for its file formats; they're vendored under xsd/. At build time, build.rs feeds the XSD of every active feature to xsd-parser — in a single generation pass, so formats that share common schemas (like types/base.xsd, which defines positionType, boolType, colorType, ...) get one compatible copy of those types instead of each its own — patching a few SUMO-specific XSD quirks along the way (unresolved DTD entities, type names that collide with XSD primitives, and a custom naming strategy that keeps case-sensitive enumeration values like state="M" vs. state="m" from colliding into the same Rust identifier). See FEATURE_SCHEMAS in build.rs.

Each format has 2 layers of types, and a conversion step between them. For net, a .net.xml file becomes a Network like this (routes mirrors it, schema::RoutesType becoming a routes::domain::Routes):

.net.xml --(xsd-parser, at build time)--> schema --(schema_mapper)--> domain
  • schema (layer 1; generated, see build.rs) — an almost literal mirror of the SUMO XSDs. Private, not just discouraged: see below. Shared by every active feature, so schema::NetType and schema::RoutesType live in the same module when both net and routes are enabled.
  • schema_mapper (src/net/schema_mapper.rs, src/routes/schema_mapper.rs) — converts layer 1 into layer 2, interpreting SUMO's text-encoded positions, shapes, boundaries, unions, and enumerations along the way. For example, schema::LaneType::shape is a raw String ("0.00,0.00 100.00,0.00"); net's schema_mapper turns it into a domain::Shape (a Vec<domain::Point>).
  • domain (layer 2; src/net/domain.rs, src/routes/domain.rs) — each format's own types (Network, Edge, Lane, ... for net; Route, VehicleType, Vehicle, ... for routes), independent of SUMO/XSD and of each other. This is what consumers build on.

read_network / routes::read_routes run the whole pipeline for their format — deserializing into layer 1, then converting via schema_mapper into layer 2 — so consumers only ever see domain types, and never have to name a generated schema type or depend on xsd-parser-types themselves.

Layer 1 being private is a deliberate semver decision, not tidiness. The generated types implement xsd-parser-types' traits (WithDeserializer, DeserializeBytes, ...) in 173 impls, so a public schema would put that crate into this one's public API — and since a trait is identified by the version of the crate defining it, WithDeserializer from 0.2 and from 0.3 are different traits. Bumping the dependency would then break every consumer built on layer 1, forcing a breaking release over a change that has nothing to do with this crate's own API. Layer 2 never mentions xsd-parser-types, so keeping layer 1 private is what lets that dependency stay an implementation detail. (uom is the opposite case, and is re-exported: domain::Lane::length is a Length, so it is public API by design rather than by accident.)

Because layer 1 is regenerated from the XSDs on every build, keeping up with a new SUMO release is usually a matter of dropping in the updated xsd/ directory. What is hand-maintained is only the handful of patches in build.rs and each format's own schema_mapper.rs.

What routes covers

Only the building blocks of a traffic demand file are modeled: Route, VehicleType (SUMO's vType), and Vehicle. routesType also allows flow, trip, person, personFlow, container, containerFlow, interval, include, vTypeDistribution, and routeDistribution elements — read_routes silently drops them rather than erroring, same as how net's domain::Network doesn't cover every attribute net_file.xsd allows either. See the doc comments on routes::domain's types for exactly what's mapped from each SUMO element.

What additional covers

Only the detector definitions: E1Detector (induction loop), E2Detector (lane area) and E3Detector (multi-entry / multi-exit), plus the detEntry/detExit gates of the last one. additionalType allows about forty more element kinds — busStop, chargingStation, parkingArea, rerouter, calibrator, variableSpeedSign, WAUT, poly, poi, and the whole of routesType over again — and read_additional silently drops them, same as routes drops the parts of its own format it doesn't model.

SUMO spells each detector two ways (e1Detector/inductionLoop, e2Detector/laneAreaDetector, e3Detector/entryExitDetector): two element names bound to one XSD type. Both spellings land in the same field.

By default this is a reader, like the rest of the crate: it parses a .add.xml someone else wrote. Enable write for the other direction — see Writing.

Writing

The write feature adds write_network/write_network_to, routes::write_routes/write_routes_to, and additional::write_additional/write_additional_to: each takes a domain value by reference and produces .net.xml/.rou.xml/.add.xml bytes, running the read pipeline backwards (domain -> a per-format schema_writer -> the same generated schema types -> XML).

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let network = sumo_types::read_network(std::path::Path::new("city.net.xml"))?;
    sumo_types::write_network(std::path::Path::new("city-copy.net.xml"), &network)?;

    Ok(())
}

(ignore, not no_run like the other examples on this page: ReadmeExamples in src/lib.rs compiles this whole file's examples together under one #[cfg] that only requires net+routes+additional, not write — so unlike the others, this one isn't guaranteed to compile whenever ReadmeExamples itself does. The real, feature-gated, compiled version of this example lives on write_network's own doc comment in src/net/writer.rs.)

What a write_* produces round-trips through the matching read_* — every format's writer has a test doing exactly that — but it isn't a byte-for-byte copy of what netconvert/duarouter/netedit themselves would write: attributes this crate doesn't model (see "What routes/additional covers" above, and net's own domain doc comments) are simply never written, the same way read_* silently drops them on the way in, and a handful of attributes this crate does model but that SUMO itself treats as equivalent to "absent" (pass="false", keepClear="true", function="normal") are written as absent rather than spelled out, because the reader already can't tell the two apart. Length/Velocity/Time values that came from one of .net.xml's real xsd:float attributes (a lane's speed/width, for instance) are narrowed back to f32 on the way out, the same precision the schema layer stored them at on the way in.

This feature roughly doubles the code build.rs generates (serializers alongside the deserializers), so it's opt-in rather than bundled into net/routes/additional — a read-only consumer shouldn't pay that compile-time cost. It's meaningless without a format to write, and enabling it alone (--features write, none of net/routes/additional) fails the same way the empty feature set does.

Errors

Every read_*/write_* returns Result<_, sumo_types::Error> — a typed, #[non_exhaustive] enum built with thiserror, not an erased anyhow::Error. So "the file wasn't there", "the root element was wrong", and "a coordinate was NaN" are separate variants a caller can match on rather than three strings that have to be pattern-matched by hand:

use sumo_types::Error;

fn main() {
    match sumo_types::read_network(std::path::Path::new("city.net.xml")) {
        Ok(network) => println!("{} edges", network.edges.len()),
        Err(Error::Open { path, .. }) => eprintln!("no network at {}", path.display()),
        Err(Error::WrongRoot { found, .. }) => eprintln!("that's a <{found}> file, not a network"),
        Err(other) => eprintln!("{other}"),
    }
}

The same reasoning that keeps xsd-parser-types out of the public API applies here: an anyhow::Error return type would make anyhow part of this crate's API, so a major bump there would be a breaking change here. thiserror is a derive-only dependency that appears in no signature, so it isn't. (build.rs still uses anyhow, which is right for a build script — it's an executable whose errors only ever get printed.)

Units

Physical quantities are uom types (Length, Velocity, Time) rather than bare numbers, so a lane's length can't be silently mixed up with its speed and unit conversion is a .get::<_>() call instead of a magic number. uom is re-exported as sumo_types::uom so you can build values with the exact version this crate was compiled against.

Adding another format

Most of the work is mechanical, because the generation layer is already format-agnostic: two of the three SUMO-specific quirks build.rs patches around live in types/base.xsd, which 46 of the 75 vendored schemas include, and apply to every schema alike. The third, PER_FILE_PATCHES, is keyed by file name for quirks specific to one schema. routes and additional were both added this way; for another one:

  1. Add a feature for it in Cargo.toml.
  2. Add its entry XSD to FEATURE_SCHEMAS in build.rs.
  3. Add its own domain, schema_mapper, and reader files (under src/<format>/, mirroring src/net/ or src/routes/) — a Route is not an Edge. Decide whether to flatten its API to the crate root (like net) or namespace it under its own module (like routes); the latter is simpler once more than one non-net format exists, since only one of them can occupy the crate root's domain/read_* names.
  4. Have that reader call xml::ensure_root_is after deserializing. xsd-parser generates deserializers for XSD types, not elements, so nothing else checks the root element's name — and since most SUMO schemas make their content optional, an unrelated document otherwise parses into an empty, plausible-looking value instead of an error.
  5. Anything a crate-level (//!) doc comment mentions has to be written as a plain code span, not an intra-doc link, and any example in one needs ignore rather than no_run unless the format is a default feature. Those comments aren't attached to a single item, so they can't be #[cfg]-gated: they are rendered and compiled under every feature combination, and a link to a feature-gated item is a broken link in each build that doesn't enable it. Put the real, runnable example on the reader function's own doc comment, where the feature gate applies naturally, and let the README doctests (ReadmeExamples in src/lib.rs) cover the cross-format story.
  6. Optionally, add write support: a schema_writer module (the reverse of schema_mapper — impl From<&domain::X> for schema::XType/ TryFrom<&domain::X> for the top-level type, gated #[cfg(feature = "write")]) and a writer module (write_<format>/write_<format>_to, mirroring reader's read_<format>/read_<format>_from, built on xml::write_document/write_document_at). A round-trip test — write a sample value, read it back, assert equality — is what actually catches the union-priority and anonymous-choice-naming pitfalls the next two bullets describe; a schema_mapper conversion looking right in isolation isn't enough — routes had two conversions that looked correct and silently failed on every real document, and only a round-trip caught them (see the TryFrom<schema::ColorType> and TryFrom<schema::DepartType> doc comments in src/routes/schema_mapper.rs). Any doc-comment example that reaches a write_* function needs the same ignore-vs-no_run judgment call as point 5 — see the note beside this README's own write_network example.

Some schemas need a PER_FILE_PATCHES entry before they generate at all. additional_file.xsd needed two, both documented at that constant in build.rs:

  • Untouched it fails with UnknownType(fileOptionType). The file never mentions fileOptionType; it drags it in through types/metadata.xsd, which includes the 13 *ConfigurationType.xsd files describing every SUMO tool's command-line options. All additional_file.xsd wants from that subtree is one optional <metadata> provenance element, so the patch drops both and the include graph collapses to the same route.xsd + taz.xsd + base.xsd shape the other two formats already generate from. The cost is that <metadata> can't be read.
  • xsd-parser names the type behind an anonymous xsd:choice after a global counter over everything generated so far, so e3Detector's detEntry/detExit choice came out as E3DetectorContent75Type with every format enabled but E3DetectorContent70Type with only additional — a type name that shifts with the consumer's feature selection, which schema_mapper cannot write down. The patch hoists the choice into a named xsd:group, so the name derives from the group (E3DetectorDetGateGroupType) and is stable everywhere. Watch for this in any new schema with an inline xsd:choice.

types/route.xsd needed the same anonymous-choice fix, three times over, once write made it load-bearing rather than cosmetic: on the read side an unstable field name is invisible (nothing has to write it down), but schema_writer has to construct vTypeType's car-following choice and vehicleType's route/<stop> sequences to leave them empty, and doing that means naming the field. vTypeCarFollowingGroup, vehicleRouteChoiceGroup, and vehicleStopGroup hoist those three into named groups the same way detGateGroup does above.

Every patch is matched against literal text from Eclipse SUMO's schemas and fails the build if it stops matching, which is what should happen when xsd/ is re-vendored from a newer SUMO.

Scope of the published package

Only the schemas the implemented features need (net_file.xsd, routes_file.xsd, additional_file.xsd, types/base.xsd, types/route.xsd, types/taz.xsd) are published to crates.io — see include in Cargo.toml. The remaining 69 vendored schemas (detector outputs, tool configurations, ...) stay in the git repository so that adding a reader for another format later doesn't mean re-vendoring them; they'd be added to include alongside that format's feature.

License

This crate's own code (src/, build.rs) is licensed under either of Apache License, Version 2.0 or MIT license at your option.

The schemas under xsd/ are third-party material redistributed verbatim from Eclipse SUMO under EPL-2.0, and are not covered by that dual license — see NOTICE. Because the published package contains both, its crates.io license field is the combined expression (MIT OR Apache-2.0) AND EPL-2.0, which is what license scanners need to see; it describes the tarball, not just the Rust source.