Data preparation guide
Request accessData preparation guide
This guide explains how to prepare your data for use with GEM, including the required schema, code examples, validation techniques, and best practices.
GEM supports two matching modes, each with its own input and output schema. Choose the section that matches your use case:
| Matching mode | Use it to… | Input | Section |
|---|---|---|---|
| Road matching | Match road segments to GERS IDs | Parquet with id, is_navigable, geometry | Road matching |
| Lane-level matching | Match GPS traces to individual lanes | Parquet or CSV traces | Lane-level matching |
Road matching
Road matching takes road segments and matches them to the road network, returning a GERS ID per segment.
Data format requirements
Road matching requires input data in Apache Parquet format with a specific schema.
Required schema
| Field | Type | Description | Example |
|---|---|---|---|
id | integer or string | Unique identifier for each road segment | 5707295 |
is_navigable | boolean | Whether the road is navigable by vehicles | true |
geometry | string | Road geometry in WKT LineString format | "LINESTRING (145.18 -37.87, 145.18 -37.87)" |
Geometry format
The geometry field must contain valid Well-Known Text (WKT) LineString geometries:
LINESTRING (longitude1 latitude1, longitude2 latitude2, ...)Examples:
LINESTRING (145.18156 -37.87340, 145.18092 -37.87356)LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)Creating Parquet files
Using Python (pandas + pyarrow)
import pandas as pdimport pyarrow as paimport pyarrow.parquet as pq
# Create sample datadata = { 'id': [1, 2, 3, 4, 5], 'is_navigable': [True, True, False, True, True], '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)', 'LINESTRING (4.8990 52.3740, 4.9005 52.3755)', 'LINESTRING (4.9005 52.3755, 4.9020 52.3770)' ]}
# Create DataFramedf = pd.DataFrame(data)
# Define schema with correct typesschema = pa.schema([ ('id', pa.int64()), ('is_navigable', pa.bool_()), ('geometry', pa.string())])
# Convert to PyArrow Table with schematable = pa.Table.from_pandas(df, schema=schema)
# Write to Parquetpq.write_table(table, 'my_road_data.parquet')
print(f"Created Parquet file with {len(df)} records")Using Python (GeoPandas)
If your data is already in a geospatial format (Shapefile, GeoJSON, etc.):
import geopandas as gpdimport pandas as pd
# Read source datagdf = gpd.read_file('roads.shp')
# Prepare for GEMgem_data = pd.DataFrame({ 'id': range(1, len(gdf) + 1), # Generate unique IDs 'is_navigable': gdf['navigable'].fillna(True), # Default to True 'geometry': gdf.geometry.apply(lambda g: g.wkt) # Convert to WKT})
# Filter to LineStrings onlygem_data = gem_data[gem_data['geometry'].str.startswith('LINESTRING')]
# Save as Parquetgem_data.to_parquet('gem_input.parquet', index=False)
print(f"Exported {len(gem_data)} road segments")Using PySpark
For large datasets:
from pyspark.sql import SparkSessionfrom pyspark.sql.types import StructType, StructField, LongType, BooleanType, StringType
# Initialize Sparkspark = SparkSession.builder.appName("GEM Data Prep").getOrCreate()
# Define schemaschema = StructType([ StructField("id", LongType(), False), StructField("is_navigable", BooleanType(), False), StructField("geometry", StringType(), False)])
# Read your source datasource_df = spark.read.format("your_format").load("your_data")
# Transform to GEM schemagem_df = source_df.select( source_df["road_id"].alias("id"), source_df["navigable"].alias("is_navigable"), source_df["wkt_geometry"].alias("geometry"))
# Write as Parquetgem_df.write.parquet("gem_input.parquet")Data validation
Always validate your data before uploading to GEM.
Python validation script
import pandas as pdimport re
def validate_gem_data(filepath): """Validate a Parquet file for GEM compatibility."""
print(f"Validating: {filepath}") errors = [] warnings = []
# Read the file try: df = pd.read_parquet(filepath) except Exception as e: return [f"Cannot read file: {e}"], []
print(f"Total records: {len(df)}")
# Check required columns required_cols = ['id', 'is_navigable', 'geometry'] missing_cols = [c for c in required_cols if c not in df.columns] if missing_cols: errors.append(f"Missing required columns: {missing_cols}") return errors, warnings
# Check for null values for col in required_cols: null_count = df[col].isnull().sum() if null_count > 0: errors.append(f"Column '{col}' has {null_count} null values")
# Check ID uniqueness duplicate_ids = df['id'].duplicated().sum() if duplicate_ids > 0: errors.append(f"Found {duplicate_ids} duplicate IDs")
# Check data types if not pd.api.types.is_integer_dtype(df['id']): errors.append(f"Column 'id' should be integer, got {df['id'].dtype}")
if not pd.api.types.is_bool_dtype(df['is_navigable']): errors.append(f"Column 'is_navigable' should be boolean, got {df['is_navigable'].dtype}")
# Validate geometries linestring_pattern = r'^LINESTRING\s*\([^)]+\)$' invalid_geom = 0 for idx, geom in df['geometry'].items(): if not isinstance(geom, str): invalid_geom += 1 elif not re.match(linestring_pattern, geom.strip(), re.IGNORECASE): invalid_geom += 1
if invalid_geom > 0: errors.append(f"Found {invalid_geom} invalid geometries (must be WKT LINESTRING)")
# Check for empty geometries empty_geom = df['geometry'].str.contains(r'LINESTRING\s*\(\s*\)', case=False, regex=True).sum() if empty_geom > 0: warnings.append(f"Found {empty_geom} empty geometries")
# Summary print(f"\nValidation Results:") print(f" Errors: {len(errors)}") print(f" Warnings: {len(warnings)}")
if errors: print("\nErrors:") for e in errors: print(f" ❌ {e}")
if warnings: print("\nWarnings:") for w in warnings: print(f" ⚠️ {w}")
if not errors: print("\n✅ File is valid for GEM!")
return errors, warnings
# Usageerrors, warnings = validate_gem_data('my_road_data.parquet')Common data quality issues
Issue 1: Invalid geometry format
Problem: Geometries not in WKT LineString format.
Solution:
from shapely import wktfrom shapely.geometry import LineString
def fix_geometry(geom): """Convert various geometry formats to WKT LineString.""" try: # If it's already a valid WKT string parsed = wkt.loads(geom) if isinstance(parsed, LineString): return geom else: return None # Not a LineString except: return None
df['geometry'] = df['geometry'].apply(fix_geometry)df = df.dropna(subset=['geometry'])Issue 2: Duplicate IDs
Problem: Multiple records share the same ID.
Solution:
# Option 1: Keep first occurrencedf = df.drop_duplicates(subset=['id'], keep='first')
# Option 2: Regenerate IDsdf['id'] = range(1, len(df) + 1)Issue 3: Mixed geometry types
Problem: Dataset contains Points, Polygons, etc. alongside LineStrings.
Solution:
# Filter to LineStrings onlydf = df[df['geometry'].str.upper().str.startswith('LINESTRING')]Issue 4: Coordinate system issues
Problem: Coordinates in wrong order or projection.
Solution:
import geopandas as gpdfrom shapely import wkt
# Read and reprojectgdf = gpd.read_file('roads.shp')gdf = gdf.to_crs('EPSG:4326') # Convert to WGS84
# Extract WKTdf['geometry'] = gdf.geometry.apply(lambda g: g.wkt)Best practices
Before uploading
- Start small: Test with a subset (1,000-10,000 records) before processing full dataset
- Validate thoroughly: Run validation script on every file
- Check file size: Large files may take longer to upload; plan accordingly
- Use descriptive filenames:
city_roads_2024_v1.parquetnotdata.parquet
Data quality tips
- Clean geometries: Remove self-intersections and invalid geometries
- Ensure connectivity: Connected road networks match better than isolated segments
- Include all segments: Don’t filter out small roads—they help with context
- Accurate navigability: Set
is_navigablecorrectly for better matching
File naming conventions
Recommended naming pattern:
{region}_{data_type}_{date}_{version}.parquetExamples:
netherlands_roads_20240115_v1.parquetcalifornia_highways_20240120_v2.parquettokyo_streets_20240118_final.parquet
Sample data
Here’s a minimal sample file you can use for testing:
import pandas as pd
# Sample Amsterdam road segmentssample_data = { 'id': [1, 2, 3, 4, 5], 'is_navigable': [True, True, True, True, False], '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)', 'LINESTRING (4.8990 52.3740, 4.9005 52.3755)', 'LINESTRING (4.9005 52.3755, 4.9020 52.3770)' ]}
df = pd.DataFrame(sample_data)df.to_parquet('sample_gem_input.parquet', index=False)print("Sample file created: sample_gem_input.parquet")Output data schema
| Field | Type | Description |
|---|---|---|
id | string or integer | Your original road segment ID |
gers | string | Matched GERS ID (UUID format) |
confidence | integer | Match confidence score (0-100) |
lr_id | string | Linear reference: coordinates and GERS ID |
lr_gers | string | Linear reference: distance range and original ID |
Example:
{"id":"abc","gers":"550e8400-e29b-41d4-a716-446655440000","confidence":99,"lr_id":"52.0197-76.36744#550e8400-e29b-41d4-a716-446655440000","lr_gers":"0.0-100.0#abc"}Lane-level matching
Lane-level matching takes GPS traces as input and matches each trace to the road network, producing per-lane matches with geometry and confidence.
Traces can be supplied in two formats, selected automatically from the file extension:
- Parquet (
.parquet) — trace records in the trace schema below. This is the native format and is processed directly. - CSV (
.csv) — converted to the sameTraceParquet records on ingest, then processed identically.
Parquet traces ──────────────────────────────┐ ├─→ prepare → match → pack → results.parquetCSV traces → (convert to Parquet traces) ─────┘Both paths converge on the same Trace schema, so the schema below is what your Parquet file must contain — and what a CSV is converted into. Uploaded files must end in .parquet or .csv; the results are written as <input-name>.results.parquet.
Trace schema
A trace is a Trace record containing an ordered list of TracePoints. This is the logical model behind both the Parquet and CSV inputs.
Trace
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique trace identifier |
points | array of TracePoint | Yes | Ordered sequence of trace points |
TracePoint
| Field | Type | Required | Description |
|---|---|---|---|
coord | Coord | Yes | Point coordinates |
timestamp | long | Yes | Unix timestamp (ms) or sequential index |
heading | double | Yes | Bearing in degrees, 0–360 |
velocity | double | Yes | Velocity (m/s); 0.0 when unknown |
lane | long | No | Pre-assigned lane id, if available (nullable) |
Coord
| Field | Type | Required | Description |
|---|---|---|---|
x | double | Yes | Longitude (WGS84) |
y | double | Yes | Latitude (WGS84) |
Parquet input
Parquet is the native input format. Each row is one Trace, with points stored as an array of structs and coord as a nested struct — so the schema is nested, not flat.
Parquet schema
root |-- id: string (nullable = true) |-- points: array (nullable = true) | |-- element: struct (containsNull = true) | | |-- coord: struct (nullable = true) | | | |-- x: double (nullable = false) # longitude (WGS84) | | | |-- y: double (nullable = false) # latitude (WGS84) | | |-- heading: double (nullable = false) # degrees, 0–360 | | |-- lane: long (nullable = true) # optional pre-assigned lane id | | |-- timestamp: long (nullable = false) # ms or sequential index | | |-- velocity: double (nullable = false) # m/s, 0.0 if unknownField order within a struct is not significant — fields are read by name. What matters is the nesting, the names, and the types.
Creating a Parquet trace file (PySpark)
Building the nested structure is easiest with Spark, which is also what the pipeline uses internally:
from pyspark.sql import SparkSessionfrom pyspark.sql.types import ( StructType, StructField, StringType, DoubleType, LongType, ArrayType)
spark = SparkSession.builder.appName("GEM Trace Prep").getOrCreate()
coord = StructType([ StructField("x", DoubleType(), False), # longitude StructField("y", DoubleType(), False), # latitude])
trace_point = StructType([ StructField("coord", coord, True), StructField("heading", DoubleType(), False), StructField("lane", LongType(), True), StructField("timestamp", LongType(), False), StructField("velocity", DoubleType(), False),])
trace_schema = StructType([ StructField("id", StringType(), True), StructField("points", ArrayType(trace_point, True), True),])
rows = [ ("trace-001", [ {"coord": {"x": 4.8952, "y": 52.3702}, "heading": 34.2, "lane": None, "timestamp": 0, "velocity": 0.0}, {"coord": {"x": 4.8960, "y": 52.3710}, "heading": 34.2, "lane": None, "timestamp": 1, "velocity": 0.0}, ]),]
spark.createDataFrame(rows, trace_schema).write.mode("overwrite").parquet("traces.parquet")CSV input
When you upload a .csv file, the pipeline converts each row into the Trace schema above. Two CSV formats are supported, detected automatically from the CSV column headers.
| Format | Required columns | Geometry | Resulting trace |
|---|---|---|---|
Road profile (road_category, road_waviness) | uuid, shape | WKT LINESTRING | Multi-point trace — one point per vertex |
Road events (road_events) | uuid, heading, coordinate | WKT POINT | Single-point trace |
Road profile format
Use this format when each trace is a polyline (a driven path or road profile).
| Column | Type | Description | Example |
|---|---|---|---|
uuid | string | Unique trace identifier → Trace.id | trace-001 |
shape | string | Trace geometry in WKT LINESTRING format | "LINESTRING (4.8952 52.3702, 4.8960 52.3710)" |
For each vertex in shape, one TracePoint is created:
- The heading is computed automatically from consecutive vertices (bearing in degrees,
0–360). - The timestamp is set to the vertex index (
0,1,2, …). - The velocity is set to
0.0. - The lane is left unset (
null).
Road events format
Use this format when each trace is a single point with a known heading (for example, a road event observation).
| Column | Type | Description | Example |
|---|---|---|---|
uuid | string | Unique trace identifier → Trace.id | event-001 |
heading | number | Heading/bearing in degrees, 0–360 | 92.5 |
coordinate | string | Point geometry in WKT POINT format | "POINT (4.8952 52.3702)" |
Each row produces a single-point trace. The timestamp and velocity are set to 0.
Geometry format
CSV geometries use Well-Known Text (WKT) with longitude first, latitude second (WGS84 / EPSG:4326):
LINESTRING (longitude1 latitude1, longitude2 latitude2, ...)POINT (longitude latitude)Examples:
LINESTRING (145.18156 -37.87340, 145.18092 -37.87356)LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)POINT (4.8952 52.3702)Creating CSV files
Road profile CSV:
import csv
# Trace polylines as WKT LINESTRINGstraces = [ ("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 f: writer = csv.writer(f) writer.writerow(["uuid", "shape"]) writer.writerows(traces)
print(f"Wrote {len(traces)} traces")Road events CSV:
import csv
# Single-point events: (uuid, heading, WKT POINT)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 f: writer = csv.writer(f) writer.writerow(["uuid", "heading", "coordinate"]) writer.writerows(events)
print(f"Wrote {len(events)} events")Converting geospatial data to a road profile CSV:
import geopandas as gpdimport pandas as pd
# Read and reproject to WGS84gdf = gpd.read_file("roads.shp").to_crs("EPSG:4326")
# Keep LineStrings only and export the road profile columnsprofile = pd.DataFrame({ "uuid": [f"trace-{i}" for i in range(len(gdf))], "shape": gdf.geometry.apply(lambda g: g.wkt),})profile = profile[profile["shape"].str.upper().str.startswith("LINESTRING")]
profile.to_csv("road_profile.csv", index=False)print(f"Exported {len(profile)} traces")Data validation
Validating a Parquet trace file (PySpark)
Read the file and confirm the nested schema and that no trace is empty:
from pyspark.sql import functions as F
df = spark.read.parquet("traces.parquet")df.printSchema() # Compare against the Parquet schema above
assert "id" in df.columns and "points" in df.columns, "Missing id/points columns"
problems = df.filter( F.col("id").isNull() | (F.size("points") == 0)).count()print("OK" if problems == 0 else f"{problems} traces with null id or no points")Validating a CSV file
import csvimport re
LINESTRING_RE = re.compile(r"^LINESTRING\s*\([^)]+\)$", re.IGNORECASE)POINT_RE = re.compile(r"^POINT\s*\([^)]+\)$", re.IGNORECASE)
def validate_traces(filepath): """Validate a road profile or road events CSV for GEM lane matching.""" errors, warnings = [], []
with open(filepath, newline="") as f: reader = csv.DictReader(f) columns = set(reader.fieldnames or [])
# Detect format from headers if {"uuid", "shape"}.issubset(columns): fmt, geom_col, geom_re = "road_profile", "shape", LINESTRING_RE elif {"uuid", "heading", "coordinate"}.issubset(columns): fmt, geom_col, geom_re = "road_events", "coordinate", POINT_RE else: return [f"Unrecognized columns: {sorted(columns)}"], []
print(f"Detected format: {fmt}")
seen_ids = set() for line_no, row in enumerate(reader, start=2): uuid = (row.get("uuid") or "").strip() if not uuid: errors.append(f"Line {line_no}: missing uuid") elif uuid in seen_ids: errors.append(f"Line {line_no}: duplicate uuid '{uuid}'") else: seen_ids.add(uuid)
geom = (row.get(geom_col) or "").strip() if not geom_re.match(geom): errors.append(f"Line {line_no}: invalid {geom_col} geometry")
if fmt == "road_events": heading = (row.get("heading") or "").strip() try: value = float(heading) if not 0.0 <= value < 360.0: warnings.append(f"Line {line_no}: heading {value} outside [0, 360)") except ValueError: errors.append(f"Line {line_no}: heading '{heading}' is not a number")
print(f"\nErrors: {len(errors)} Warnings: {len(warnings)}") for error in errors: print(f" ❌ {error}") for warning in warnings: print(f" ⚠️ {warning}") if not errors: print("\n✅ File is valid for GEM lane matching!")
return errors, warnings
# Usageerrors, warnings = validate_traces("road_profile.csv")Common data quality issues
Issue 1: Flat Parquet instead of nested
Problem: The Parquet file has flat columns (for example x, y, heading per row) instead of a points array of structs.
Solution: Group your points per trace and build the nested points array as shown in Creating a Parquet trace file. Each row must be a whole trace, not a single point.
Issue 2: Invalid geometry format (CSV)
Problem: shape/coordinate values are not valid WKT.
Solution:
from shapely import wktfrom shapely.geometry import LineString, Point
def is_valid(geom, expected): try: return isinstance(wkt.loads(geom), expected) except Exception: return False
# Road profiledf = df[df["shape"].apply(lambda g: is_valid(g, LineString))]# Road eventsdf = df[df["coordinate"].apply(lambda g: is_valid(g, Point))]Issue 3: Duplicate trace IDs
Problem: Multiple traces share the same id (uuid).
Solution:
# CSV: keep the first occurrencedf = df.drop_duplicates(subset=["uuid"], keep="first")Issue 4: Wrong coordinate order or projection
Problem: Coordinates are in latitude/longitude order or a non-WGS84 projection. Coordinates must be longitude first, in EPSG:4326.
Solution:
import geopandas as gpd
gdf = gpd.read_file("roads.shp").to_crs("EPSG:4326") # Reproject to WGS84df["shape"] = gdf.geometry.apply(lambda g: g.wkt) # lon lat orderBest practices
Before running
- Prefer Parquet for repeated or large runs: it skips the CSV conversion step and is the native format.
- Start small: Test with a subset (1,000–10,000 traces) before processing the full dataset.
- Validate thoroughly: Check the schema (Parquet) or headers (CSV) on every file.
- Use one CSV format per file: Do not mix road profile and road events columns in a single CSV.
- Use descriptive filenames:
netherlands_traces_20240115_v1.parquet, notdata.parquet.
Data quality tips
- Clean geometries: Remove empty, self-intersecting, or degenerate geometries.
- Consistent WGS84: Keep all coordinates in EPSG:4326 with longitude first.
- Meaningful headings: For road events, provide accurate headings — they drive directional matching.
- Stable IDs: Keep trace ids stable across runs so you can join matches back to your source data.
Output data schema
Each input trace produces one Match. A match contains the matched road segments; each road segment optionally carries lane-level details.
Match
| Field | Type | Description |
|---|---|---|
traceId | string | The id of the input trace |
roads | array of RoadMatch | Matched road segments (empty if no route was found) |
RoadMatch
| Field | Type | Description |
|---|---|---|
roadAreaId | string | Identifier of the matched road area |
startConnectorId | string | Connector id at the start of the matched segment |
endConnectorId | string | Connector id at the end of the matched segment |
lane | LaneMatch | Lane-level match details (nullable) |
LaneMatch
| Field | Type | Description |
|---|---|---|
laneId | string | Identifier of the matched lane |
wkt | string | Lane geometry as a WKT LINESTRING |
confidence | double | Lane match confidence score |
startOffset | double | Offset along the road to the start of the lane match (nullable) |
endOffset | double | Offset along the road to the end of the lane match (nullable) |
Output Parquet schema
The results file (<input-name>.results.parquet) is nested, mirroring the model above:
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)Example (one match, shown as JSON):
{ "traceId": "trace-001", "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 } } ]}Next steps
Once your data is prepared and validated:
- UI Workflow Guide - Upload through the web interface
- API Workflow Guide - Upload and manage data through the API
- Quick Reference - Command cheat sheet