Lane-level matching data
Request accessLane-level matching data
Lane-level matching (LANE_LEVEL_MATCHING) matches GPS traces to the roads and lanes of TomTom Orbis Maps. For every trace it returns the matched road areas and lanes.
At a glance
| Input | Results |
|---|---|
One .parquet file with one trace per row, or one .csv file | One .parquet file, one row per trace |
Parquet: id and a nested list of points | Columns: traceId, mapRevisionId, roads |
CSV: road profile (uuid, shape) or road events (uuid, heading, coordinate) | Matched road areas and lanes, identified by TomTom Orbis Maps IDs |
Parquet input
Each row is one trace: an id and the list of its points in the order they were driven. The points are nested structs, so the schema is nested, not flat:
root |-- id: string |-- points: array | |-- element: struct | | |-- coord: struct | | | |-- x: double | | | |-- y: double | | |-- heading: double | | |-- lane: long | | |-- timestamp: long | | |-- velocity: doubleTrace
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Trace ID, not empty. Use unique IDs, so you can join the results back to your data. |
points | list of points | Yes | At least one point, in the order they were driven |
Point
| Field | Type | Required | Description |
|---|---|---|---|
coord | struct with x and y (double) | Yes | Position in WGS84: x is the longitude, y is the latitude |
heading | double | Yes | Direction of travel in degrees, clockwise from north, 0–360 |
timestamp | long | Yes | Unix time in milliseconds, or a sequence number |
velocity | double | Yes | Speed in m/s. Use 0.0 when unknown. |
lane | long | Yes, can be empty | Lane ID when known, otherwise empty (null) |
Rules for the Parquet file:
- Every field of the schema must be present, including
lane,timestamp, andvelocity. Field names are not case-sensitive. - The types must match exactly. For example,
timestampmust be a 64-bit integer (long), not adouble. - Only
lanecan be empty. The nullability flags of the schema don’t matter. - Other fields are allowed.
CSV input
GEM converts a CSV file into the same traces. It recognizes two layouts by their header row:
| Layout | Columns | Geometry | Trace |
|---|---|---|---|
| Road profile | uuid, shape | WKT LINESTRING | One point for every vertex of the line |
| Road events | uuid, heading, coordinate | WKT POINT | One point |
Rules for the CSV file:
- Separate the values with commas.
- Write the header names exactly as shown: lowercase, without spaces.
- Put WKT values that contain commas, such as
LINESTRINGvalues, in double quotes. CSV writers do this automatically. - Use one layout per file. If a file has the columns of both layouts, GEM reads it as a road profile.
- Other columns are allowed.
- Write coordinates in WGS84, longitude first:
LINESTRING (longitude1 latitude1, longitude2 latitude2, ...)andPOINT (longitude latitude).
How rows become traces:
- Road profile:
uuidbecomes the trace ID, and every vertex ofshapebecomes a point. The heading of a point is the bearing to the next vertex, and the last point keeps the heading of the one before it. The timestamp is the vertex index (0,1,2, …), the velocity is0.0, and the lane is empty. - Road events:
uuidbecomes the trace ID, and the row becomes a single point with the givenheading. The timestamp and the velocity are0.
Example input
A Parquet trace, shown as JSON:
{ "id": "trace-001", "points": [ {"coord": {"x": 4.8952, "y": 52.3702}, "heading": 34.2, "lane": null, "timestamp": 1760000000000, "velocity": 13.9}, {"coord": {"x": 4.8960, "y": 52.3710}, "heading": 34.5, "lane": null, "timestamp": 1760000001000, "velocity": 14.1} ]}A road profile CSV:
uuid,shapetrace-001,"LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)"trace-002,"LINESTRING (4.8990 52.3740, 4.9005 52.3755, 4.9020 52.3770)"A road events CSV:
uuid,heading,coordinateevent-001,92.5,POINT (4.8952 52.3702)event-002,180.0,POINT (4.8990 52.3740)Create the file
Write your traces to a single file. Keep your own trace IDs, so you can join the results back to your data.
Parquet (pyarrow)
import pyarrow as paimport pyarrow.parquet as pq
coord = pa.struct([pa.field("x", pa.float64()), pa.field("y", pa.float64())])point = pa.struct([ pa.field("coord", coord), pa.field("heading", pa.float64()), pa.field("lane", pa.int64()), pa.field("timestamp", pa.int64()), pa.field("velocity", pa.float64()),])schema = pa.schema([pa.field("id", pa.string()), pa.field("points", pa.list_(point))])
traces = [ { "id": "trace-001", "points": [ {"coord": {"x": 4.8952, "y": 52.3702}, "heading": 34.2, "lane": None, "timestamp": 1760000000000, "velocity": 13.9}, {"coord": {"x": 4.8960, "y": 52.3710}, "heading": 34.5, "lane": None, "timestamp": 1760000001000, "velocity": 14.1}, ], },]pq.write_table(pa.Table.from_pylist(traces, schema=schema), "traces.parquet")Parquet (PySpark)
Spark writes a folder of part files. Write one partition and upload its part file under the name you want to use in GEM:
from pyspark.sql import SparkSessionfrom pyspark.sql.types import ArrayType, DoubleType, LongType, StringType, StructField, StructType
spark = SparkSession.builder.appName("gem-trace-input").getOrCreate()
coord = StructType([StructField("x", DoubleType(), False), StructField("y", DoubleType(), False)])point = StructType([ StructField("coord", coord, True), StructField("heading", DoubleType(), False), StructField("lane", LongType(), True), StructField("timestamp", LongType(), False), StructField("velocity", DoubleType(), False),])schema = StructType([StructField("id", StringType(), True), StructField("points", ArrayType(point, True), True)])
rows = [ ("trace-001", [ {"coord": {"x": 4.8952, "y": 52.3702}, "heading": 34.2, "lane": None, "timestamp": 1760000000000, "velocity": 13.9}, {"coord": {"x": 4.8960, "y": 52.3710}, "heading": 34.5, "lane": None, "timestamp": 1760000001000, "velocity": 14.1}, ]),]spark.createDataFrame(rows, schema).coalesce(1).write.mode("overwrite").parquet("traces_spark")az storage blob upload --account-name "<STORAGE_ACCOUNT_NAME>" \ --container-name "default" \ --name "traces.parquet" \ --file "$(ls traces_spark/part-*.parquet)" \ --auth-mode loginCSV
import csv
profiles = [ ("trace-001", "LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)"), ("trace-002", "LINESTRING (4.8990 52.3740, 4.9005 52.3755, 4.9020 52.3770)"),]with open("road_profile.csv", "w", newline="") as file: writer = csv.writer(file) writer.writerow(["uuid", "shape"]) writer.writerows(profiles)
events = [ ("event-001", 92.5, "POINT (4.8952 52.3702)"), ("event-002", 180.0, "POINT (4.8990 52.3740)"),]with open("road_events.csv", "w", newline="") as file: writer = csv.writer(file) writer.writerow(["uuid", "heading", "coordinate"]) writer.writerows(events)Road profile CSV from GIS data (GeoPandas)
import geopandas as gpdimport pandas as pd
drives = gpd.read_file("drives.gpkg").to_crs("EPSG:4326").explode(index_parts=False)drives = drives[drives.geom_type == "LineString"].reset_index(drop=True)
part = drives.groupby("drive_id").cumcount()drive_id = drives["drive_id"].astype(str)
pd.DataFrame({ "uuid": drive_id.where(part == 0, drive_id + "-" + part.astype(str)), "shape": drives.geometry.to_wkt(),}).to_csv("road_profile.csv", index=False)Replace drive_id with the ID column of your data. Multi-part lines become one trace per part, and every part after the first gets a -1, -2, … suffix.
Validate before upload
These scripts check your file against the rules on this page. They need Python 3 with pyarrow (Parquet) or shapely (CSV):
pip install pyarrow shapelypython validate_traces_parquet.py traces.parquetpython validate_traces_csv.py road_profile.csvParquet
"""Check a lane-level matching trace file (Parquet) before uploading it to GEM.
Usage: python validate_traces_parquet.py <file.parquet>"""import sysfrom pathlib import Path
import pyarrow as paimport pyarrow.compute as pcimport pyarrow.parquet as pq
POINT_TYPES = {"heading": pa.float64(), "lane": pa.int64(), "timestamp": pa.int64(), "velocity": pa.float64()}REQUIRED_VALUES = ["heading", "timestamp", "velocity"]
def by_name(fields): return {field.name.lower(): field for field in fields}
def child(structs, field): return pc.struct_field(structs, [structs.type.get_field_index(field.name)])
def count(mask): return pc.sum(mask).as_py() or 0
def schema_errors(schema): columns = by_name(schema) errors = [] if "id" not in columns: errors.append("Column 'id' is missing.") elif columns["id"].type not in (pa.string(), pa.large_string()): errors.append(f"Column 'id' must be a string, not '{columns['id'].type}'.") points = columns.get("points") if points is None: return errors + ["Column 'points' is missing. Store one trace per row, with its points in a list."] is_list = pa.types.is_list(points.type) or pa.types.is_large_list(points.type) if not is_list or not pa.types.is_struct(points.type.value_type): return errors + [f"Column 'points' must be a list of structs, not '{points.type}'."] fields = by_name(points.type.value_type) coord = fields.get("coord") if coord is None or not pa.types.is_struct(coord.type): errors.append("Field 'points.coord' must be a struct with the fields 'x' and 'y'.") else: axes = by_name(coord.type) for axis in ("x", "y"): if axis not in axes or axes[axis].type != pa.float64(): errors.append(f"Field 'points.coord.{axis}' must be a double.") for name, expected in POINT_TYPES.items(): if name not in fields: errors.append(f"Field 'points.{name}' is missing.") elif fields[name].type != expected: errors.append(f"Field 'points.{name}' must be '{expected}', not '{fields[name].type}'.") return errors
def data_findings(path, schema): columns = by_name(schema) fields = by_name(columns["points"].type.value_type) axes = by_name(fields["coord"].type) no_id = no_points = empty_points = no_coordinates = duplicates = outside = bad_heading = 0 missing = dict.fromkeys(REQUIRED_VALUES, 0) seen = set() for batch in pq.ParquetFile(path).iter_batches(columns=[columns["id"].name, columns["points"].name]): ids, points = batch.column(columns["id"].name), batch.column(columns["points"].name) no_id += count(pc.fill_null(pc.equal(ids, ""), True)) no_points += count(pc.fill_null(pc.equal(pc.list_value_length(points), 0), True)) for trace_id in ids.drop_null().to_pylist(): duplicates += trace_id in seen seen.add(trace_id)
flat = pc.list_flatten(points) empty_points += flat.null_count flat = flat.filter(pc.is_valid(flat)) for name in REQUIRED_VALUES: missing[name] += child(flat, fields[name]).null_count coord = child(flat, fields["coord"]) x, y = child(coord, axes["x"]), child(coord, axes["y"]) no_coordinates += count(pc.or_(pc.is_null(x), pc.is_null(y))) outside += count(pc.or_(pc.greater(pc.abs(x), 180), pc.greater(pc.abs(y), 90))) heading = child(flat, fields["heading"]) bad_heading += count(pc.or_(pc.less(heading, 0), pc.greater(heading, 360)))
errors, warnings = [], [] if no_id: errors.append(f"{no_id} trace(s) have no 'id'.") if no_points: errors.append(f"{no_points} trace(s) have no points.") if empty_points: errors.append(f"{empty_points} point(s) are empty.") if no_coordinates: errors.append(f"{no_coordinates} point(s) have no coordinates.") errors += [f"{missing_count} point(s) have no '{name}' value." for name, missing_count in missing.items() if missing_count] if duplicates: warnings.append(f"{duplicates} trace(s) reuse the 'id' of an earlier trace. " "Use unique IDs to join the results back to your data.") if outside: warnings.append(f"{outside} point(s) have coordinates outside the longitude/latitude range. " "Use WGS84 with x = longitude and y = latitude.") if bad_heading: warnings.append(f"{bad_heading} point(s) have a heading outside 0-360 degrees.") return errors, warnings
def main(file_name): path = Path(file_name) if not path.is_file(): print(f"ERROR: {path} does not exist.") return 1 try: schema = pq.read_schema(path) except pa.ArrowInvalid: print(f"ERROR: {path.name} is not a valid Parquet file.") return 1
errors, warnings = schema_errors(schema), [] if not errors: errors, warnings = data_findings(path, schema)
for message in errors: print(f"ERROR: {message}") for message in warnings: print(f"WARNING: {message}") if not errors: print(f"OK: {path.name} is ready for lane-level matching.") return 1 if errors else 0
if __name__ == "__main__": if len(sys.argv) != 2: sys.exit(__doc__) sys.exit(main(sys.argv[1]))CSV
"""Check a lane-level matching trace file (CSV) before uploading it to GEM.
Usage: python validate_traces_csv.py <file.csv>"""import csvimport sysfrom pathlib import Path
import shapely
LAYOUTS = { "road profile": ("uuid", "shape"), "road events": ("uuid", "heading", "coordinate"),}GEOMETRIES = {"road profile": ("shape", "LineString"), "road events": ("coordinate", "Point")}MAX_LISTED = 20
def detect_layout(header): for layout, columns in LAYOUTS.items(): if set(columns) <= set(header): return layout return None
def header_error(header): expected = {column for columns in LAYOUTS.values() for column in columns} misspelled = [name for name in header if name not in expected and name.strip().lower() in expected] hint = f" Fix {misspelled}: header names are case-sensitive and can't contain spaces." if misspelled else "" return (f"Unrecognized header {header}. Use 'uuid,shape' for a road profile " f"or 'uuid,heading,coordinate' for road events.{hint}")
def row_findings(reader, layout): errors, seen = [], set() duplicates = outside = bad_heading = 0 column, geometry_type = GEOMETRIES[layout] for line, row in enumerate(reader, start=2): trace_id = (row.get("uuid") or "").strip() if not trace_id: errors.append(f"Line {line}: 'uuid' is empty.") elif trace_id in seen: duplicates += 1 seen.add(trace_id)
geometry = shapely.from_wkt(row.get(column) or "", on_invalid="ignore") if geometry is None or geometry.geom_type != geometry_type or geometry.is_empty: errors.append(f"Line {line}: '{column}' is not a valid WKT {geometry_type.upper()}.") else: x_min, y_min, x_max, y_max = geometry.bounds outside += max(abs(x_min), abs(x_max)) > 180 or max(abs(y_min), abs(y_max)) > 90
if layout == "road events": try: bad_heading += not 0 <= float(row.get("heading") or "") <= 360 except ValueError: errors.append(f"Line {line}: 'heading' is not a number.")
warnings = [] if duplicates: warnings.append(f"{duplicates} row(s) reuse the 'uuid' of an earlier row. " "Use unique IDs to join the results back to your data.") if outside: warnings.append(f"{outside} row(s) have coordinates outside the longitude/latitude range. " "Use WGS84 with longitude first.") if bad_heading: warnings.append(f"{bad_heading} row(s) have a heading outside 0-360 degrees.") return errors, warnings
def main(file_name): path = Path(file_name) if not path.is_file(): print(f"ERROR: {path} does not exist.") return 1 with path.open(newline="", encoding="utf-8-sig") as file: if "," not in file.readline(): print("ERROR: The header has no commas. Separate the values with commas.") return 1 file.seek(0) reader = csv.DictReader(file) header = reader.fieldnames or [] layout = detect_layout(header) if layout is None: print(f"ERROR: {header_error(header)}") return 1 errors, warnings = row_findings(reader, layout)
if layout == "road profile" and set(LAYOUTS["road events"]) <= set(header): warnings.append("The header has the columns of both layouts, so GEM reads the file as a road profile.")
for message in errors[:MAX_LISTED]: print(f"ERROR: {message}") if len(errors) > MAX_LISTED: print(f"ERROR: ... and {len(errors) - MAX_LISTED} more.") for message in warnings: print(f"WARNING: {message}") if not errors: print(f"OK: {path.name} is ready for lane-level matching ({layout}).") return 1 if errors else 0
if __name__ == "__main__": if len(sys.argv) != 2: sys.exit(__doc__) sys.exit(main(sys.argv[1]))Results
GEM writes the results to <name>.results.parquet, next to your input file. Every trace with an ID and at least one point gets one row. The rows are not in input order, so join them to your data on traceId.
| Column | Type | Description |
|---|---|---|
traceId | string | ID of your trace: id in Parquet, uuid in CSV |
mapRevisionId | long | Revision of TomTom Orbis Maps that the trace was matched against |
roads | list of road matches | Matched road areas. Empty when GEM found no route for the trace. |
Road match
| Field | Type | Description |
|---|---|---|
roadAreaId | string | ID of the matched road area |
startConnectorId | string | ID of the connector where the match starts. Can be empty. |
endConnectorId | string | ID of the connector where the match ends. Can be empty. |
lane | lane match | The matched lane |
Lane match
| Field | Type | Description |
|---|---|---|
laneId | string | ID of the matched lane |
wkt | string | Geometry of the lane as WKT LINESTRING |
confidence | double | Confidence of the lane match |
startOffset | double | Where the match starts, in meters along the lane from its start |
endOffset | double | Where the match ends, in meters along the lane from its start |
IDs are TomTom Orbis Maps IDs in the format <layer>:<high>:<low>.
The results file has this schema:
root |-- roads: array (nullable = true) | |-- element: struct (containsNull = true) | | |-- endConnectorId: string (nullable = true) | | |-- lane: struct (nullable = true) | | | |-- confidence: double (nullable = false) | | | |-- endOffset: double (nullable = true) | | | |-- laneId: string (nullable = true) | | | |-- startOffset: double (nullable = true) | | | |-- wkt: string (nullable = true) | | |-- roadAreaId: string (nullable = true) | | |-- startConnectorId: string (nullable = true) |-- traceId: string (nullable = true) |-- mapRevisionId: long (nullable = false)A result row, shown as JSON (the values are illustrative):
{ "traceId": "trace-001", "mapRevisionId": 2760154, "roads": [ { "roadAreaId": "12345:678:90", "startConnectorId": "12345:678:11", "endConnectorId": "12345:678:12", "lane": { "laneId": "12345:678:42", "wkt": "LINESTRING (4.8952 52.3702, 4.8960 52.3710)", "confidence": 100.0, "startOffset": 0.0, "endOffset": 37.5 } } ]}Common problems
| Problem | Fix |
|---|---|
| The Parquet file has one point per row | Group the points of each trace into the nested points list, one trace per row. |
The schema has no lane, timestamp, or velocity field | Add the field. When you don’t have the values, use an empty lane, a sequence number as timestamp, and 0.0 as velocity. |
timestamp is a double | Store timestamp as a 64-bit integer. |
| Matches follow the wrong road or lane | Order the points as they were driven, and check that heading is in degrees clockwise from north. |
| The CSV header isn’t recognized | Separate the values with commas, and write the header exactly as uuid,shape or uuid,heading,coordinate. |
roads is empty for a trace | Check that the coordinates are WGS84 with longitude first and lie on mapped roads. Split long drives into shorter traces. |
Next steps
- Data preparation - File requirements for all matching types
- UI workflow guide - Upload your file and run a job in the UI
- API workflow guide - Upload your file and run a job with the API
- Troubleshooting - Solutions for common problems