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. |