Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 6 additions & 0 deletions src/pyopendrive/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
OpenDriveVersion,
RegulationSemantic,
Road,
RoadLink,
RoadRegulation,
RoadSpeed,
RoadType,
SignalRegulation,
)
from pyopendrive.odr.api import OpenDrive
Expand All @@ -27,7 +30,10 @@
"OpenDriveParseError",
"OpenDriveVersion",
"Road",
"RoadLink",
"RoadRegulation",
"RoadSpeed",
"RoadType",
"RegulationSemantic",
"SignalRegulation",
]
6 changes: 6 additions & 0 deletions src/pyopendrive/odr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
OpenDriveVersion,
RegulationSemantic,
Road,
RoadLink,
RoadRegulation,
RoadSpeed,
RoadType,
SignalRegulation,
)
from pyopendrive.odr.parser import OpenDriveParseError
Expand All @@ -27,7 +30,10 @@
"OpenDriveParseError",
"OpenDriveVersion",
"Road",
"RoadLink",
"RoadRegulation",
"RoadSpeed",
"RoadType",
"RegulationSemantic",
"SignalRegulation",
]
5 changes: 4 additions & 1 deletion src/pyopendrive/odr/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
OpenDriveSource,
OpenDriveVersion,
RegulationSemantic,
Road,
RoadRegulation,
SignalRegulation,
)
from pyopendrive.odr.models.road import Road, RoadLink, RoadSpeed, RoadType

__all__ = [
"DefaultRegulations",
Expand All @@ -25,7 +25,10 @@
"OpenDriveSource",
"OpenDriveVersion",
"Road",
"RoadLink",
"RoadRegulation",
"RoadSpeed",
"RoadType",
"RegulationSemantic",
"SignalRegulation",
]
10 changes: 2 additions & 8 deletions src/pyopendrive/odr/models/opendrive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down Expand Up @@ -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."""
Expand Down
39 changes: 39 additions & 0 deletions src/pyopendrive/odr/models/road.py
Original file line number Diff line number Diff line change
@@ -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, ...] = ()
148 changes: 144 additions & 4 deletions src/pyopendrive/odr/parser/xml.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Internal XML-to-model conversion."""

import math
from pathlib import Path
from typing import IO
from xml.etree import ElementTree
Expand All @@ -14,7 +15,10 @@
OpenDriveVersion,
RegulationSemantic,
Road,
RoadLink,
RoadRegulation,
RoadSpeed,
RoadType,
SignalRegulation,
)

Expand Down Expand Up @@ -68,25 +72,124 @@ 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(
id=road_id,
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")
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading