Skip to main content

sumo_types/additional/
schema_mapper.rs

1//! Conversion from layer 1 (the private `schema` module, generated by
2//! xsd-parser from the SUMO XSDs) to layer 2
3//! ([`crate::additional::domain`]).
4//!
5//! Mirrors the other formats' mappers in spirit, but is independent code:
6//! `additional` doesn't require `net` or `routes` to be enabled, so it
7//! can't reuse their conversions.
8//!
9//! The one thing worth knowing here is that SUMO's `floatType` is declared
10//! in `types/base.xsd` as an `xsd:string` restricted by a pattern, not as
11//! `xsd:float` — so xsd-parser hands positions over as `String` and this
12//! module parses them. `positiveFloatType` and `nonNegativeFloatType` *are*
13//! real `xsd:float`s, and arrive as `f32`.
14
15use super::domain::{
16    Additional, DetectorGate, DetectorId, E1Detector, E2Detector, E3Detector, LaneCoverage,
17    LanePosition, LaneRef, PersonMode, Point,
18};
19use crate::schema;
20use crate::sumo::{parse_bool_opt, parse_finite, split_ids};
21use crate::{Error, Result};
22use uom::si::f64::{Length, Time, Velocity};
23use uom::si::length::meter;
24use uom::si::time::second;
25use uom::si::velocity::meter_per_second;
26
27/// SUMO packs "from the start" and "from the end" into the sign of one
28/// number; [`LanePosition`] splits them, so the sign is read here and
29/// nowhere else.
30fn lane_position(raw: &str, context: &'static str) -> Result<LanePosition> {
31    let value = parse_finite(raw, context)?;
32
33    Ok(if value < 0.0 {
34        LanePosition::FromEnd(Length::new::<meter>(-value))
35    } else {
36        LanePosition::FromStart(Length::new::<meter>(value))
37    })
38}
39
40/// Parses `e3Detector/@pos`, the icon position: `"x,y"` or `"x,y,z"`. Same
41/// encoding as every other SUMO position even though the XSD types this one
42/// attribute as a bare `xsd:string`; only the error context differs, which
43/// is what [`Point::parse`] takes over `Point`'s `TryFrom<&str>`.
44fn icon_position(raw: &str) -> Result<Point> {
45    Point::parse(raw, "detector icon position")
46}
47
48fn seconds(value: Option<f32>) -> Option<Time> {
49    value.map(|s| Time::new::<second>(f64::from(s)))
50}
51
52fn meters(value: Option<f32>) -> Option<Length> {
53    value.map(|m| Length::new::<meter>(f64::from(m)))
54}
55
56/// SUMO's `period` supersedes the deprecated `freq`; both mean the same
57/// sampling interval, so fall back to `freq` when only it is present.
58fn sampling_period(period: Option<f32>, freq: Option<f32>) -> Option<Time> {
59    seconds(period.or(freq))
60}
61
62impl TryFrom<schema::DetEntryExitType> for DetectorGate {
63    type Error = Error;
64
65    fn try_from(value: schema::DetEntryExitType) -> Result<Self> {
66        Ok(DetectorGate {
67            lane: LaneRef(value.lane),
68            position: lane_position(&value.pos, "detector gate position")?,
69            friendly_position: parse_bool_opt(value.friendly_pos)?,
70        })
71    }
72}
73
74impl TryFrom<schema::E1DetectorType> for E1Detector {
75    type Error = Error;
76
77    fn try_from(value: schema::E1DetectorType) -> Result<Self> {
78        Ok(E1Detector {
79            id: DetectorId(value.id),
80            lane: LaneRef(value.lane),
81            position: lane_position(&value.pos, "e1Detector position")?,
82            file: value.file,
83            period: sampling_period(value.period, value.freq),
84            length: meters(value.length),
85            name: value.name,
86            friendly_position: parse_bool_opt(value.friendly_pos)?,
87        })
88    }
89}
90
91/// Resolves `e2Detector`'s `lane` / `lanes` / `length` trio, rejecting the
92/// two combinations SUMO itself rejects.
93///
94/// Per SUMO's documentation for `lanes`: *"This argument excludes the
95/// arguments lane and length."* So `lanes` is exclusive with both of the
96/// others, while `lane` + `length` is the normal single-anchor form.
97///
98/// This is input validation, not an invariant of [`LaneCoverage`]: a
99/// malformed document should fail here, at the parse boundary, the same way
100/// a `NaN` position or a wrong root element does. Folding `length` into the
101/// `SingleLane` variant to make the bad state unrepresentable was
102/// considered and rejected — `length` can legally appear with neither
103/// attribute set, and it would have had nowhere to live, turning a visible
104/// error into silent data loss.
105fn lane_coverage(
106    lane: Option<String>,
107    lanes: Option<&str>,
108    length: Option<&str>,
109) -> Result<Option<LaneCoverage>> {
110    match (lane, lanes) {
111        (Some(_), Some(_)) => Err(Error::ConflictingAttributes {
112            element: "e2Detector",
113            first: "lane",
114            second: "lanes",
115        }),
116        (_, Some(_)) if length.is_some() => Err(Error::ConflictingAttributes {
117            element: "e2Detector",
118            first: "lanes",
119            second: "length",
120        }),
121        (Some(lane), None) => Ok(Some(LaneCoverage::SingleLane(LaneRef(lane)))),
122        (None, Some(lanes)) => Ok(Some(LaneCoverage::LaneChain(split_ids(lanes)))),
123        (None, None) => Ok(None),
124    }
125}
126
127impl TryFrom<schema::E2DetectorType> for E2Detector {
128    type Error = Error;
129
130    fn try_from(value: schema::E2DetectorType) -> Result<Self> {
131        Ok(E2Detector {
132            id: DetectorId(value.id),
133            coverage: lane_coverage(value.lane, value.lanes.as_deref(), value.length.as_deref())?,
134            position: value
135                .pos
136                .as_deref()
137                .map(|pos| lane_position(pos, "e2Detector position"))
138                .transpose()?,
139            end_position: value
140                .end_pos
141                .as_deref()
142                .map(|pos| lane_position(pos, "e2Detector end position"))
143                .transpose()?,
144            length: value
145                .length
146                .as_deref()
147                .map(|raw| parse_finite(raw, "e2Detector length"))
148                .transpose()?
149                .map(Length::new::<meter>),
150            file: value.file,
151            period: sampling_period(value.period, value.freq),
152            name: value.name,
153            friendly_position: parse_bool_opt(value.friendly_pos)?,
154            speed_threshold: value
155                .speed_threshold
156                .as_deref()
157                .map(|raw| parse_finite(raw, "e2Detector speedThreshold"))
158                .transpose()?
159                .map(Velocity::new::<meter_per_second>),
160            time_threshold: value
161                .time_threshold
162                .as_deref()
163                .map(|raw| parse_finite(raw, "e2Detector timeThreshold"))
164                .transpose()?
165                .map(Time::new::<second>),
166            jam_threshold: value
167                .jam_threshold
168                .as_deref()
169                .map(|raw| parse_finite(raw, "e2Detector jamThreshold"))
170                .transpose()?
171                .map(Length::new::<meter>),
172        })
173    }
174}
175
176/// Parses `detectPersons`'s whitespace-separated token list, same layout
177/// [`split_ids`] reads for id lists — but unlike an id, an unrecognized
178/// token here is malformed input rather than another valid value, so this
179/// can't just be `split_ids::<PersonMode>` the way e.g. `LaneRef`'s list is.
180fn parse_person_modes(raw: &str) -> Result<Vec<PersonMode>> {
181    raw.split_whitespace()
182        .map(|token| token.parse::<PersonMode>().map_err(|err| Error::InvalidPersonMode { value: err.0 }))
183        .collect()
184}
185
186impl TryFrom<schema::E3DetectorType> for E3Detector {
187    type Error = Error;
188
189    fn try_from(value: schema::E3DetectorType) -> Result<Self> {
190        let mut entries = Vec::new();
191        let mut exits = Vec::new();
192
193        for item in value.content.det_gate_group {
194            match item.content {
195                schema::E3DetectorDetGateGroupTypeContent::detEntry(gates) => {
196                    for gate in gates {
197                        entries.push(DetectorGate::try_from(gate)?);
198                    }
199                }
200                schema::E3DetectorDetGateGroupTypeContent::detExit(gates) => {
201                    for gate in gates {
202                        exits.push(DetectorGate::try_from(gate)?);
203                    }
204                }
205            }
206        }
207
208        Ok(E3Detector {
209            id: DetectorId(value.id),
210            entries,
211            exits,
212            file: value.file,
213            period: sampling_period(value.period, value.freq),
214            name: value.name,
215            icon_position: value.pos.as_deref().map(icon_position).transpose()?,
216            speed_threshold: value
217                .speed_threshold
218                .map(|s| Velocity::new::<meter_per_second>(f64::from(s))),
219            time_threshold: value
220                .time_threshold
221                .as_deref()
222                .map(|raw| parse_finite(raw, "e3Detector timeThreshold"))
223                .transpose()?
224                .map(Time::new::<second>),
225            open_entry: parse_bool_opt(value.open_entry)?,
226            detect_persons: value.detect_persons.as_deref().map(parse_person_modes).transpose()?.unwrap_or_default(),
227        })
228    }
229}
230
231impl TryFrom<schema::AdditionalType> for Additional {
232    type Error = Error;
233
234    fn try_from(value: schema::AdditionalType) -> Result<Self> {
235        let mut additional = Additional::default();
236
237        for item in value.content {
238            match item {
239                // `inductionLoop`, `laneAreaDetector` and `entryExitDetector`
240                // are SUMO's alternative spellings of `e1Detector`,
241                // `e2Detector` and `e3Detector`: the same XSD type under a
242                // second element name, so both map to the same domain type.
243                schema::AdditionalTypeContent::e1Detector(Some(detector))
244                | schema::AdditionalTypeContent::inductionLoop(Some(detector)) => {
245                    additional
246                        .induction_loops
247                        .push(E1Detector::try_from(detector)?);
248                }
249                schema::AdditionalTypeContent::e2Detector(Some(detector))
250                | schema::AdditionalTypeContent::laneAreaDetector(Some(detector)) => {
251                    additional
252                        .lane_area_detectors
253                        .push(E2Detector::try_from(detector)?);
254                }
255                schema::AdditionalTypeContent::e3Detector(Some(detector))
256                | schema::AdditionalTypeContent::entryExitDetector(Some(detector)) => {
257                    additional
258                        .entry_exit_detectors
259                        .push(E3Detector::try_from(detector)?);
260                }
261                // Every other `additionalType` element kind (`busStop`,
262                // `rerouter`, `calibrator`, `parkingArea`, `poly`, the whole
263                // of `routesType` again, ...), and any of the above that
264                // showed up as `None` (shouldn't happen per the XSD, but
265                // xsd-parser models each choice branch as optional) — not
266                // modelled, see the `domain` module docs.
267                _ => {}
268            }
269        }
270
271        Ok(additional)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn reads_the_sign_of_a_position_as_which_end_it_is_measured_from() {
281        assert_eq!(
282            lane_position("12.5", "test").unwrap(),
283            LanePosition::FromStart(Length::new::<meter>(12.5))
284        );
285        // Negative means "back from the lane's end", and the variant says
286        // so instead of leaving a signed number for the caller to misread.
287        assert_eq!(
288            lane_position("-12.5", "test").unwrap(),
289            LanePosition::FromEnd(Length::new::<meter>(12.5))
290        );
291    }
292
293    #[test]
294    fn rejects_the_lane_combinations_sumo_rejects() {
295        // `lanes` excludes both `lane` and `length`.
296        assert!(lane_coverage(Some("e0_0".into()), Some("e0_0 e1_0"), None).is_err());
297        assert!(lane_coverage(None, Some("e0_0 e1_0"), Some("50.0")).is_err());
298
299        // `lane` + `length` is the normal single-anchor form, and `length`
300        // with neither attribute is odd but not something SUMO forbids.
301        assert_eq!(
302            lane_coverage(Some("e0_0".into()), None, Some("50.0")).unwrap(),
303            Some(LaneCoverage::SingleLane(LaneRef("e0_0".into())))
304        );
305        assert_eq!(lane_coverage(None, None, Some("50.0")).unwrap(), None);
306        assert_eq!(lane_coverage(None, None, None).unwrap(), None);
307    }
308
309    #[test]
310    fn parses_the_icon_position_with_and_without_z() {
311        assert_eq!(
312            icon_position("42,7").unwrap(),
313            Point {
314                x: 42.0,
315                y: 7.0,
316                z: 0.0
317            }
318        );
319        assert_eq!(
320            icon_position("42,7,1.5").unwrap(),
321            Point {
322                x: 42.0,
323                y: 7.0,
324                z: 1.5
325            }
326        );
327        assert!(icon_position("42").is_err());
328        assert!(icon_position("42,7,1,9").is_err());
329    }
330
331    #[test]
332    fn falls_back_from_period_to_the_deprecated_freq() {
333        use uom::si::time::second;
334
335        assert_eq!(
336            sampling_period(Some(60.0), None).unwrap().get::<second>(),
337            60.0
338        );
339        assert_eq!(
340            sampling_period(None, Some(30.0)).unwrap().get::<second>(),
341            30.0
342        );
343        // `period` wins when SUMO wrote both.
344        assert_eq!(
345            sampling_period(Some(60.0), Some(30.0))
346                .unwrap()
347                .get::<second>(),
348            60.0
349        );
350        assert!(sampling_period(None, None).is_none());
351    }
352
353    #[test]
354    fn splits_a_lane_chain() {
355        assert_eq!(
356            split_ids::<LaneRef>("e0_0 e1_0  e2_0"),
357            vec![
358                LaneRef("e0_0".into()),
359                LaneRef("e1_0".into()),
360                LaneRef("e2_0".into())
361            ]
362        );
363    }
364
365    #[test]
366    fn rejects_bool_without_binary_meaning() {
367        assert!(parse_bool_opt(Some(schema::BoolType::x)).is_err());
368        assert_eq!(parse_bool_opt(None).unwrap(), None);
369        assert_eq!(
370            parse_bool_opt(Some(schema::BoolType::True)).unwrap(),
371            Some(true)
372        );
373    }
374}