Global Entity Matcher (GEM)

Road matching data

Request access

Road matching data

Road matching (ROAD_MATCHING) matches your road segments to the road network of Overture Maps. For every match it returns the GERS ID of the matched Overture segment and the matched parts of both lines.

At a glance

InputResults
One .parquet file, one row per road segmentOne .parquet file, one row per match
Required columns: id, geometryColumns: id, gers, confidence, lr_id, lr_gers
Optional column: is_navigableOptional GeoJSON visualization data

Input columns

ColumnTypeRequiredRulesExample
idinteger (32- or 64-bit) or stringYesUnique for every row. The results return it as a string.5707295
geometrystringYesRoad geometry as WKT LINESTRING in WGS84 (EPSG:4326), longitude firstLINESTRING (4.8952 52.3702, 4.8960 52.3710)
is_navigablebooleanNotrue when vehicles can drive on the road. Without this column, GEM treats all roads as navigable. An empty value counts as not navigable.true

Your file can contain other columns as well.

Example input

idgeometryis_navigable
5707295LINESTRING (4.8952 52.3702, 4.8960 52.3710)true
5707296LINESTRING (4.8960 52.3710, 4.8975 52.3725)true
5707297LINESTRING (4.8975 52.3725, 4.8990 52.3740)false

Checks GEM runs

When a job starts, GEM checks the input file in the order below. If a check fails, the job ends with status FAILED, and the message appears on the job details page in the UI and in error_messages in the API. In the messages, <filename> is the file name without its folder.

CheckMessageFix
The file existsInput file '<filename>' was not found in the storage location.Re-upload the file and submit a new job
The file is a valid Parquet fileInput file '<filename>' is not a valid Parquet file.Check the file with a Parquet tool and re-upload it
The required columns are presentInput file '<filename>' is missing required column(s): '<column>'. Required columns: 'id', 'geometry'.Add the columns, see Input columns
The columns have supported typesColumn '<column>' in input file '<filename>' has unsupported type '<type>'; expected 'integer' or 'string'.Convert the column, see Common problems
The IDs are uniqueColumn 'id' in input file '<filename>' contains <N> duplicated value(s). Each road must have a unique 'id'.Remove or renumber the duplicated IDs
The geometries are validColumn 'geometry' in input file '<filename>' contains <N> value(s) that are not valid WKT LINESTRING geometries.Make every geometry value a valid WKT LINESTRING string

The type message names the type the column needs: 'integer' or 'string' for id, 'string' for geometry, and 'boolean' for is_navigable. GEM reports missing columns and unsupported types together, and duplicated IDs and invalid geometries together.

To find these problems before you upload the file, run the validation script.

Create the file

Write your data to a single Parquet file. Keep your own road IDs, so you can join the results back to your data.

From a table (pandas)

create_roads.py
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
roads = pd.DataFrame({
"id": [5707295, 5707296, 5707297],
"geometry": [
"LINESTRING (4.8952 52.3702, 4.8960 52.3710)",
"LINESTRING (4.8960 52.3710, 4.8975 52.3725)",
"LINESTRING (4.8975 52.3725, 4.8990 52.3740)",
],
"is_navigable": [True, True, False],
})
schema = pa.schema([("id", pa.int64()), ("geometry", pa.string()), ("is_navigable", pa.bool_())])
pq.write_table(pa.Table.from_pandas(roads, schema=schema, preserve_index=False), "roads.parquet")

From GIS data (GeoPandas)

GeoPandas reads Shapefile, GeoJSON, GeoPackage, and other GIS formats. GEM needs geometries as WKT text, so convert them with to_wkt() and write the file with pandas. GeoDataFrame.to_parquet stores geometries as WKB binary instead.

convert_roads.py
import geopandas as gpd
import pandas as pd
roads = gpd.read_file("roads.shp").to_crs("EPSG:4326").explode(index_parts=False)
roads = roads[roads.geom_type == "LineString"].reset_index(drop=True)
part = roads.groupby("road_id").cumcount()
road_id = roads["road_id"].astype(str)
pd.DataFrame({
"id": road_id.where(part == 0, road_id + "-" + part.astype(str)),
"geometry": roads.geometry.to_wkt(),
}).to_parquet("roads.parquet", index=False)

Replace road_id with the ID column of your data. Multi-part lines become one row per part, and every part after the first gets a -1, -2, … suffix. If your data says which roads are navigable, add it as the is_navigable column.

Large datasets (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:

convert_roads_spark.py
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("gem-road-input").getOrCreate()
roads = spark.read.parquet("source_roads").select(
F.col("road_id").alias("id"),
F.col("wkt").alias("geometry"),
F.col("navigable").cast("boolean").alias("is_navigable"),
)
roads.coalesce(1).write.mode("overwrite").parquet("roads_spark")
az storage blob upload --account-name "<STORAGE_ACCOUNT_NAME>" \
--container-name "default" \
--name "roads.parquet" \
--file "$(ls roads_spark/part-*.parquet)" \
--auth-mode login

Validate before upload

This script runs the same checks as GEM and prints the same messages. It also warns about problems that lead to poor matches. It needs Python 3 with pyarrow and shapely:

pip install pyarrow shapely
python validate_road_input.py roads.parquet
validate_road_input.py
"""Check a road matching input file before uploading it to GEM.
Usage: python validate_road_input.py <file.parquet>
"""
import sys
from pathlib import Path
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
import shapely
REQUIRED_COLUMNS = ["id", "geometry"]
EXPECTED_TYPES = {
"id": ([pa.int32(), pa.int64(), pa.string(), pa.large_string()], "'integer' or 'string'"),
"geometry": ([pa.string(), pa.large_string()], "'string'"),
"is_navigable": ([pa.bool_()], "'boolean'"),
}
TYPE_NAMES = {"int8": "tinyint", "int16": "smallint", "int32": "int", "int64": "bigint",
"bool": "boolean", "large_string": "string", "large_binary": "binary"}
LINE_TYPES = [shapely.GeometryType.LINESTRING, shapely.GeometryType.LINEARRING]
def quoted(names):
return ", ".join(f"'{name}'" for name in names)
def schema_errors(schema, name):
errors = []
missing = [column for column in REQUIRED_COLUMNS if column not in schema.names]
if missing:
errors.append(f"Input file '{name}' is missing required column(s): {quoted(missing)}. "
f"Required columns: {quoted(REQUIRED_COLUMNS)}.")
for column, (allowed, expected) in EXPECTED_TYPES.items():
if column in schema.names and schema.field(column).type not in allowed:
actual = str(schema.field(column).type)
errors.append(f"Column '{column}' in input file '{name}' has unsupported type "
f"'{TYPE_NAMES.get(actual, actual)}'; expected {expected}.")
return errors
def data_findings(path, name):
errors, warnings = [], []
ids = pq.read_table(path, columns=["id"]).column("id").combine_chunks()
duplicated = pc.sum(pc.greater(pc.value_counts(ids).field("counts"), 1)).as_py() or 0
if duplicated:
errors.append(f"Column 'id' in input file '{name}' contains {duplicated} duplicated value(s). "
"Each road must have a unique 'id'.")
if ids.null_count:
warnings.append(f"Column 'id' has {ids.null_count} empty value(s). Give every road an ID.")
invalid = empty = outside = 0
for batch in pq.ParquetFile(path).iter_batches(batch_size=100_000, columns=["geometry"]):
geometries = shapely.from_wkt(batch.column(0).to_numpy(zero_copy_only=False), on_invalid="ignore")
lines = np.isin(shapely.get_type_id(geometries), LINE_TYPES)
invalid += int((~lines).sum())
empty += int((lines & shapely.is_empty(geometries)).sum())
bounds = shapely.bounds(geometries[lines])
outside += int(((np.abs(bounds[:, [0, 2]]) > 180) | (np.abs(bounds[:, [1, 3]]) > 90)).any(axis=1).sum())
if invalid:
errors.append(f"Column 'geometry' in input file '{name}' contains {invalid} value(s) "
"that are not valid WKT LINESTRING geometries.")
if empty:
warnings.append(f"Column 'geometry' has {empty} empty LINESTRING value(s), which can't be matched.")
if outside:
warnings.append(f"Column 'geometry' has {outside} value(s) with coordinates outside the "
"longitude/latitude range. Use WGS84 (EPSG:4326) with longitude first.")
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: Input file '{path.name}' is not a valid Parquet file.")
return 1
errors, warnings = schema_errors(schema, path.name), []
if not errors:
errors, warnings = data_findings(path, path.name)
if "is_navigable" not in schema.names:
warnings.append("Column 'is_navigable' is missing, so GEM treats all roads as navigable.")
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 road matching.")
return 1 if errors else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
sys.exit(main(sys.argv[1]))

Example output for a file with problems:

ERROR: Column 'id' in input file 'roads.parquet' contains 2 duplicated value(s). Each road must have a unique 'id'.
WARNING: Column 'geometry' has 15 value(s) with coordinates outside the longitude/latitude range. Use WGS84 (EPSG:4326) with longitude first.

Results

GEM writes the results to <name>.results.parquet, next to your input file. The file has one row for every match between one of your roads and a segment of the Overture release you selected. A road that matches several segments has several rows. Roads without a match have no rows.

ColumnTypeDescription
idstringID of your road
gersstringGERS ID of the matched Overture segment (UUID format)
confidenceintegerMatch confidence from 0 to 100
lr_idstringMatched part of your road, as <start>-<end>#<gers>
lr_gersstringMatched part of the Overture segment, as <start>-<end>#<id>

Linear references

lr_id and lr_gers describe which part of a line a match covers. <start> and <end> are positions along the line, as percentages of its length measured from its first point. 0.0-100.0 is the whole line. The ID after # names the line on the other side of the match.

Your road 5707296 0% 40% 100%
|===============|============================|
|---------------| lr_id 0.0-40.0#5f1a9c3b-…
|----------------------------| lr_id 40.0-100.0#9d2e71a3-…

Example results

Results for the example input could look like this. The values are illustrative.

idgersconfidencelr_idlr_gers
57072950b5c2f4e-8d3a-4c1e-9f6b-2a7d5e8c1b34970.0-100.0#0b5c2f4e-8d3a-4c1e-9f6b-2a7d5e8c1b3412.5-64.1#5707295
57072965f1a9c3b-7e2d-4b8a-a1c6-3d9e0f4b7a52880.0-40.0#5f1a9c3b-7e2d-4b8a-a1c6-3d9e0f4b7a5255.0-100.0#5707296
57072969d2e71a3-4c5b-4f0e-8b2d-6a1c3e5f7d909140.0-100.0#9d2e71a3-4c5b-4f0e-8b2d-6a1c3e5f7d900.0-38.7#5707296

Road 5707295 matches one segment along its whole length. Road 5707296 matches two segments: one for its first 40% and one for the rest. Road 5707297 has no match, so it has no rows.

Read the results

read_results.py
import pandas as pd
results = pd.read_parquet("roads.results.parquet")
for column in ("lr_id", "lr_gers"):
parts = results[column].str.extract(r"^(?P<start>[\d.]+)-(?P<end>[\d.]+)#(?P<ref>.+)$")
results[f"{column}_start"] = parts["start"].astype(float)
results[f"{column}_end"] = parts["end"].astype(float)
print(results[["id", "gers", "confidence", "lr_id_start", "lr_id_end"]])

Visualization data

When you enable visualization data (with_visualization_data: true in the API, or Generate visualization data in the UI), GEM also writes GeoJSON files for viewing the matches in GIS tools such as QGIS. They are stored next to the results file in a folder named <name>-visualization-data/, where <name> is your input file name up to its first dot. For example, input/roads.parquet produces input/roads-visualization-data/.

FileContents
all-matched.geojsonAll matched roads, with the matched parts of the Overture segments
all-unmatched.geojsonAll unmatched roads, with the candidate segments GEM considered
part_0000.geojson, part_0001.geojson, …The matched roads, split into files of about 1,000 features and grouped by location
part_0000-unmatched.geojson, …The unmatched roads, split the same way
detailed.zipOne file per road: <id>.geojson for a matched road, <id>-unmatched.geojson for an unmatched road

Every feature is a LineString with these properties:

PropertyDescription
layersrc for your road, matched for the matched part of an Overture segment, unmatched_candidate for a candidate segment of an unmatched road
src_idID of your road
trg_idGERS ID of the Overture segment
confidenceMatch confidence from 0 to 100
lr_src_start, lr_src_endMatched part of your road, in percent
lr_trg_start, lr_trg_endMatched part of the Overture segment, in percent
matched_count, unmatched_candidates, partial_rangesOn src features: the number of matched or candidate segments, and their ranges
color, line_width, line_styleStyling hints: blue for your roads, shades of red by confidence for segments

Download the folder with Azure CLI:

az storage blob download-batch --account-name "<STORAGE_ACCOUNT_NAME>" \
--source "default" \
--pattern "<name>-visualization-data/*" \
--destination "/path/to/destination" \
--auth-mode login

Common problems

ProblemFix
has unsupported type 'binary' for geometryThe file stores geometries as WKB, as GeoDataFrame.to_parquet does. Convert them to WKT text, see From GIS data.
has unsupported type 'double' for idIn pandas, missing IDs turn an integer column into floating-point numbers. Give every road an ID, or store the IDs as strings.
not valid WKT LINESTRING geometriesSplit MULTILINESTRING values into single lines and remove other geometry types.
duplicated value(s)Make the IDs unique, for example with a suffix for repeated IDs, and keep a mapping to your source data.
Spark created a folder instead of a fileUpload the single part file, see Large datasets.
Roads match poorly or not at allCheck that the coordinates are WGS84 (EPSG:4326) with longitude first.

Next steps