From 42fc2ad8ae7f24541194ab6d35c92ac746688224 Mon Sep 17 00:00:00 2001 From: "Florian K. (flok)" <37000563+flok@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:39:23 +0200 Subject: [PATCH 1/2] Parse road metadata, types, and links (#8) --- ARCHITECTURE.md | 13 ++- src/pyopendrive/__init__.py | 6 + src/pyopendrive/odr/__init__.py | 6 + src/pyopendrive/odr/models/__init__.py | 5 +- src/pyopendrive/odr/models/opendrive.py | 10 +- src/pyopendrive/odr/models/road.py | 39 +++++++ src/pyopendrive/odr/parser/xml.py | 148 +++++++++++++++++++++++- tests/test_opendrive.py | 54 ++++++++- 8 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 src/pyopendrive/odr/models/road.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 25c22ef..2767ae3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -23,7 +23,8 @@ src/pyopendrive/ ├── api.py # Compatibility loading facade ├── models/ │ ├── __init__.py - │ └── opendrive.py # Map, header, road, and format-revision models + │ ├── opendrive.py # Map, header, and format-revision models + │ └── road.py # Road metadata, links, types, and speeds └── parser/ ├── __init__.py └── xml.py # XML-to-model conversion @@ -56,10 +57,12 @@ road_map = OpenDriveMap.load("map.xodr") The parser reads `revMajor`/`revMinor`, complete header metadata (including georeference source text, offset values, license, and default regulations), and -road IDs, lengths, junction references, and names. It handles XML namespace -prefixes when comparing element names. A file header carries major/minor -revision only; patch releases cannot be inferred from it. Header offsets and -georeferences are preserved as data; coordinate transforms remain separate. +road IDs, lengths, junction references, names, rules, road types, speed +metadata, and predecessor/successor links. Road identifiers remain strings and +road type records retain document order. It handles XML namespace prefixes +when comparing element names. A file header carries major/minor revision only; +patch releases cannot be inferred from it. Header offsets and georeferences +are preserved as data; coordinate transforms remain separate. ## Design boundaries diff --git a/src/pyopendrive/__init__.py b/src/pyopendrive/__init__.py index 4074ce9..0577241 100644 --- a/src/pyopendrive/__init__.py +++ b/src/pyopendrive/__init__.py @@ -11,7 +11,10 @@ OpenDriveVersion, RegulationSemantic, Road, + RoadLink, RoadRegulation, + RoadSpeed, + RoadType, SignalRegulation, ) from pyopendrive.odr.api import OpenDrive @@ -27,7 +30,10 @@ "OpenDriveParseError", "OpenDriveVersion", "Road", + "RoadLink", "RoadRegulation", + "RoadSpeed", + "RoadType", "RegulationSemantic", "SignalRegulation", ] diff --git a/src/pyopendrive/odr/__init__.py b/src/pyopendrive/odr/__init__.py index 9abaec8..e410b52 100644 --- a/src/pyopendrive/odr/__init__.py +++ b/src/pyopendrive/odr/__init__.py @@ -11,7 +11,10 @@ OpenDriveVersion, RegulationSemantic, Road, + RoadLink, RoadRegulation, + RoadSpeed, + RoadType, SignalRegulation, ) from pyopendrive.odr.parser import OpenDriveParseError @@ -27,7 +30,10 @@ "OpenDriveParseError", "OpenDriveVersion", "Road", + "RoadLink", "RoadRegulation", + "RoadSpeed", + "RoadType", "RegulationSemantic", "SignalRegulation", ] diff --git a/src/pyopendrive/odr/models/__init__.py b/src/pyopendrive/odr/models/__init__.py index 0a71c9b..73d929e 100644 --- a/src/pyopendrive/odr/models/__init__.py +++ b/src/pyopendrive/odr/models/__init__.py @@ -10,10 +10,10 @@ OpenDriveSource, OpenDriveVersion, RegulationSemantic, - Road, RoadRegulation, SignalRegulation, ) +from pyopendrive.odr.models.road import Road, RoadLink, RoadSpeed, RoadType __all__ = [ "DefaultRegulations", @@ -25,7 +25,10 @@ "OpenDriveSource", "OpenDriveVersion", "Road", + "RoadLink", "RoadRegulation", + "RoadSpeed", + "RoadType", "RegulationSemantic", "SignalRegulation", ] diff --git a/src/pyopendrive/odr/models/opendrive.py b/src/pyopendrive/odr/models/opendrive.py index 94de430..09f85a9 100644 --- a/src/pyopendrive/odr/models/opendrive.py +++ b/src/pyopendrive/odr/models/opendrive.py @@ -4,6 +4,8 @@ from os import PathLike from typing import IO +from pyopendrive.odr.models.road import Road + type OpenDriveSource = str | PathLike[str] | IO[bytes] | IO[str] @@ -70,14 +72,6 @@ class DefaultRegulations: signals: tuple[SignalRegulation, ...] = () -@dataclass(frozen=True, slots=True) -class Road: - id: str - length: float - junction: str - name: str | None = None - - @dataclass(frozen=True, slots=True) class OpenDriveDiagnostic: """A recoverable parser observation tied to an XML element.""" diff --git a/src/pyopendrive/odr/models/road.py b/src/pyopendrive/odr/models/road.py new file mode 100644 index 0000000..75249cd --- /dev/null +++ b/src/pyopendrive/odr/models/road.py @@ -0,0 +1,39 @@ +"""Road metadata models parsed from OpenDRIVE documents.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class RoadSpeed: + value: float | str + unit: str | None = None + + +@dataclass(frozen=True, slots=True) +class RoadType: + s: float + type: str + country: str | None = None + source: str | None = None + speed: RoadSpeed | None = None + + +@dataclass(frozen=True, slots=True) +class RoadLink: + element_type: str + element_id: str + contact_point: str | None = None + element_s: float | None = None + element_dir: str | None = None + + +@dataclass(frozen=True, slots=True) +class Road: + id: str + length: float + junction: str + name: str | None = None + rule: str | None = None + predecessor: RoadLink | None = None + successor: RoadLink | None = None + types: tuple[RoadType, ...] = () diff --git a/src/pyopendrive/odr/parser/xml.py b/src/pyopendrive/odr/parser/xml.py index c94cebd..f892e69 100644 --- a/src/pyopendrive/odr/parser/xml.py +++ b/src/pyopendrive/odr/parser/xml.py @@ -1,5 +1,6 @@ """Internal XML-to-model conversion.""" +import math from pathlib import Path from typing import IO from xml.etree import ElementTree @@ -14,7 +15,10 @@ OpenDriveVersion, RegulationSemantic, Road, + RoadLink, RoadRegulation, + RoadSpeed, + RoadType, SignalRegulation, ) @@ -68,15 +72,16 @@ def parse(source: XmlSource) -> OpenDriveMap: def _parse_road(element: ElementTree.Element) -> Road: + path = f"/OpenDRIVE/road[@id='{element.get('id', '?')}']" try: road_id = element.attrib["id"] - length = float(element.attrib["length"]) + length = _float_attribute(element, "length", path) junction = element.attrib["junction"] except (KeyError, ValueError) as error: road_id = element.get("id", "?") raise OpenDriveParseError( f"Road has a missing or invalid attribute: {error}", - element=f"/OpenDRIVE/road[@id='{road_id}']", + element=path, ) from error return Road( @@ -84,9 +89,107 @@ def _parse_road(element: ElementTree.Element) -> Road: length=length, junction=junction, name=element.get("name"), + rule=element.get("rule"), + predecessor=_parse_road_link(element, "predecessor", path), + successor=_parse_road_link(element, "successor", path), + types=tuple( + _parse_road_type(child, path) + for child in element + if _local_name(child.tag) == "type" + ), + ) + + +def _parse_road_link( + road: ElementTree.Element, direction: str, path: str +) -> RoadLink | None: + container = next( + (child for child in road if _local_name(child.tag) == "link"), None + ) + if container is None: + return None + element = next( + (child for child in container if _local_name(child.tag) == direction), None + ) + if element is None: + return None + link_path = f"{path}/link/{direction}" + try: + element_type = element.attrib["elementType"] + element_id = element.attrib["elementId"] + element_s = ( + _float_attribute(element, "elementS", link_path) + if "elementS" in element.attrib + else None + ) + except (KeyError, ValueError) as error: + raise OpenDriveParseError( + f"Road link has a missing or invalid attribute: {error}", + element=link_path, + ) from error + return RoadLink( + element_type=element_type, + element_id=element_id, + contact_point=element.get("contactPoint"), + element_s=element_s, + element_dir=element.get("elementDir"), + ) + + +def _parse_road_type(element: ElementTree.Element, road_path: str) -> RoadType: + path = f"{road_path}/type" + try: + s = _float_attribute(element, "s", path) + road_type = element.attrib["type"] + except (KeyError, ValueError) as error: + raise OpenDriveParseError( + f"Road type has a missing or invalid attribute: {error}", element=path + ) from error + speed_element = next( + (child for child in element if _local_name(child.tag) == "speed"), None + ) + speed = None + if speed_element is not None: + speed_path = f"{path}/speed" + maximum = speed_element.get("max") + if maximum is None: + raise OpenDriveParseError( + "Road speed is missing required attribute 'max'.", element=speed_path + ) + if maximum in {"no limit", "undefined"}: + value: float | str = maximum + else: + try: + value = float(maximum) + if not math.isfinite(value): + raise ValueError("speed must be finite") + except ValueError as error: + raise OpenDriveParseError( + f"Road speed has invalid 'max' attribute: {error}", + element=speed_path, + ) from error + speed = RoadSpeed(value=value, unit=speed_element.get("unit")) + return RoadType( + s=s, + type=road_type, + country=element.get("country"), + source=element.get("source"), + speed=speed, ) +def _float_attribute(element: ElementTree.Element, attribute: str, path: str) -> float: + try: + value = float(element.attrib[attribute]) + if not math.isfinite(value): + raise ValueError("value must be finite") + return value + except (KeyError, ValueError) as error: + raise OpenDriveParseError( + f"Missing or invalid '{attribute}' attribute: {error}", element=path + ) from error + + def _parse_header(element: ElementTree.Element) -> Header: children = {_local_name(child.tag): child for child in element} license_element = children.get("license") @@ -268,9 +371,46 @@ def _collect_diagnostics( for road in roads: path = f"/OpenDRIVE/road[@id='{road.get('id', '?')}']" _unsupported_attributes( - road, {"id", "length", "junction", "name"}, path, diagnostics + road, {"id", "length", "junction", "name", "rule"}, path, diagnostics ) - _unsupported_children(road, path, diagnostics) + for child in road: + name = _local_name(child.tag) + if name == "link": + _unsupported_attributes(child, set(), f"{path}/link", diagnostics) + for link in child: + link_path = f"{path}/link/{_local_name(link.tag)}" + _unsupported_attributes( + link, + { + "elementType", + "elementId", + "contactPoint", + "elementS", + "elementDir", + }, + link_path, + diagnostics, + ) + _unsupported_children(link, link_path, diagnostics) + elif name == "type": + _unsupported_attributes( + child, + {"s", "type", "country", "source"}, + f"{path}/type", + diagnostics, + ) + for nested in child: + nested_name = _local_name(nested.tag) + nested_path = f"{path}/type/{nested_name}" + if nested_name == "speed": + _unsupported_attributes( + nested, {"max", "unit"}, nested_path, diagnostics + ) + _unsupported_children(nested, nested_path, diagnostics) + else: + _unsupported_element(nested, nested_path, diagnostics) + else: + _unsupported_element(child, f"{path}/{name}", diagnostics) for child in root: name = _local_name(child.tag) diff --git a/tests/test_opendrive.py b/tests/test_opendrive.py index 9d47993..02eae42 100644 --- a/tests/test_opendrive.py +++ b/tests/test_opendrive.py @@ -184,7 +184,7 @@ def test_unsupported_content_produces_structured_diagnostics() -> None: """\
- +
""" @@ -203,11 +203,6 @@ def test_unsupported_content_produces_structured_diagnostics() -> None: "Attribute 'unexpected' is not currently parsed.", "/OpenDRIVE/header", ), - OpenDriveDiagnostic( - "unsupported-attribute", - "Attribute 'rule' is not currently parsed.", - "/OpenDRIVE/road[@id='1']", - ), OpenDriveDiagnostic( "unsupported-element", "Element 'planView' is not currently parsed.", @@ -219,3 +214,50 @@ def test_unsupported_content_produces_structured_diagnostics() -> None: "/OpenDRIVE/controller", ), ) + + +def test_parses_road_types_and_links() -> None: + road_map = OpenDriveMap.load( + StringIO( + """\ + +
+ + + + + + + + + + + +""" + ) + ) + + road = road_map.roads[0] + assert road.id == "01" + assert road.rule == "RHT" + assert road.predecessor.element_id == "02" + assert road.predecessor.contact_point is None + assert road.successor.element_id == "3" + assert road.successor.contact_point == "start" + assert road.types[0].speed.value == 13.9 + assert road.types[0].speed.unit == "m/s" + assert road.types[1].speed.value == "no limit" + assert road_map.diagnostics == () + + +def test_invalid_road_type_reports_context() -> None: + with pytest.raises(OpenDriveParseError, match="Road type") as caught: + OpenDriveMap.load( + StringIO( + '
' + '' + '' + ) + ) + + assert caught.value.element == "/OpenDRIVE/road[@id='1']/type" From a2a0de905b5966c0962222277e724aca220ba3e3 Mon Sep 17 00:00:00 2001 From: "Florian K. (flok)" <37000563+flok@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:40:59 +0200 Subject: [PATCH 2/2] Update road metadata coverage (#8) --- COMPATIBILITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 2cd4134..f84e1dc 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -45,8 +45,8 @@ claim that every detail has already been implemented. | Element group | Current parser | Initial target | Notes | | --- | --- | --- | --- | | Root, format revision, complete header metadata | Supported | Supported | Parses bounds, georeference source text, coordinate offset, license, and 1.8+ default regulations; transforms remain separate. | -| Road identity and basic attributes | Partial | Supported | Current parser reads ID, length, junction reference, and name. | -| Road links, road types, rules, metadata | Planned | Supported | Parse and preserve specification-defined values. | +| Road identity and basic attributes | Supported | Supported | Parses ID, length, junction reference, name, and rule; identifiers remain strings. | +| Road links, road types, rules, metadata | Partial | Supported | Parses ordered type/speed records and predecessor/successor references; extended lane-layer state is tracked with the lane model. | | Plan-view geometry | Planned | Supported | Parse line, arc, spiral, poly3, paramPoly3 and 1.9 curve forms where specified. | | Elevation, superelevation, shape, cross-section profiles | Planned | Supported | Parse coefficients/records; evaluating geometry is outside the initial target. | | Lanes, lane links, road marks, access, speeds, widths | Planned | Supported | Includes 1.9 permanent/temporary lane layers and cross-layer links. |