Global Entity Matcher (GEM)

Lane-level matching data

Request access

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

InputResults
One .parquet file with one trace per row, or one .csv fileOne .parquet file, one row per trace
Parquet: id and a nested list of pointsColumns: 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: double

Trace

FieldTypeRequiredDescription
idstringYesTrace ID, not empty. Use unique IDs, so you can join the results back to your data.
pointslist of pointsYesAt least one point, in the order they were driven

Point

FieldTypeRequiredDescription
coordstruct with x and y (double)YesPosition in WGS84: x is the longitude, y is the latitude
headingdoubleYesDirection of travel in degrees, clockwise from north, 0–360
timestamplongYesUnix time in milliseconds, or a sequence number
velocitydoubleYesSpeed in m/s. Use 0.0 when unknown.
lanelongYes, can be emptyLane ID when known, otherwise empty (null)

Rules for the Parquet file:

  • Every field of the schema must be present, including lane, timestamp, and velocity. Field names are not case-sensitive.
  • The types must match exactly. For example, timestamp must be a 64-bit integer (long), not a double.
  • Only lane can 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:

LayoutColumnsGeometryTrace
Road profileuuid, shapeWKT LINESTRINGOne point for every vertex of the line
Road eventsuuid, heading, coordinateWKT POINTOne 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 LINESTRING values, 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, ...) and POINT (longitude latitude).

How rows become traces:

  • Road profile: uuid becomes the trace ID, and every vertex of shape becomes 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 is 0.0, and the lane is empty.
  • Road events: uuid becomes the trace ID, and the row becomes a single point with the given heading. The timestamp and the velocity are 0.

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,shape
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)"

A road events CSV:

uuid,heading,coordinate
event-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)

create_traces.py
import pyarrow as pa
import 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:

create_traces_spark.py
from pyspark.sql import SparkSession
from 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 login

CSV

create_traces_csv.py
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)

convert_traces.py
import geopandas as gpd
import 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 shapely
python validate_traces_parquet.py traces.parquet
python validate_traces_csv.py road_profile.csv

Parquet

validate_traces_parquet.py
"""Check a lane-level matching trace file (Parquet) before uploading it to GEM.
Usage: python validate_traces_parquet.py <file.parquet>
"""
import sys
from pathlib import Path
import pyarrow as pa
import pyarrow.compute as pc
import 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

validate_traces_csv.py
"""Check a lane-level matching trace file (CSV) before uploading it to GEM.
Usage: python validate_traces_csv.py <file.csv>
"""
import csv
import sys
from 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.

ColumnTypeDescription
traceIdstringID of your trace: id in Parquet, uuid in CSV
mapRevisionIdlongRevision of TomTom Orbis Maps that the trace was matched against
roadslist of road matchesMatched road areas. Empty when GEM found no route for the trace.

Road match

FieldTypeDescription
roadAreaIdstringID of the matched road area
startConnectorIdstringID of the connector where the match starts. Can be empty.
endConnectorIdstringID of the connector where the match ends. Can be empty.
lanelane matchThe matched lane

Lane match

FieldTypeDescription
laneIdstringID of the matched lane
wktstringGeometry of the lane as WKT LINESTRING
confidencedoubleConfidence of the lane match
startOffsetdoubleWhere the match starts, in meters along the lane from its start
endOffsetdoubleWhere 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

ProblemFix
The Parquet file has one point per rowGroup the points of each trace into the nested points list, one trace per row.
The schema has no lane, timestamp, or velocity fieldAdd 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 doubleStore timestamp as a 64-bit integer.
Matches follow the wrong road or laneOrder the points as they were driven, and check that heading is in degrees clockwise from north.
The CSV header isn’t recognizedSeparate the values with commas, and write the header exactly as uuid,shape or uuid,heading,coordinate.
roads is empty for a traceCheck that the coordinates are WGS84 with longitude first and lie on mapped roads. Split long drives into shorter traces.

Next steps