Data preparation guide
Data 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)
1import pandas as pd2import pyarrow as pa3import pyarrow.parquet as pq45# Create sample data6data = {7 'id': [1, 2, 3, 4, 5],8 'is_navigable': [True, True, False, True, True],9 'geometry': [10 'LINESTRING (4.8952 52.3702, 4.8960 52.3710)',11 'LINESTRING (4.8960 52.3710, 4.8975 52.3725)',12 'LINESTRING (4.8975 52.3725, 4.8990 52.3740)',13 'LINESTRING (4.8990 52.3740, 4.9005 52.3755)',14 'LINESTRING (4.9005 52.3755, 4.9020 52.3770)'15 ]16}1718# Create DataFrame19df = pd.DataFrame(data)2021# Define schema with correct types22schema = pa.schema([23 ('id', pa.int64()),24 ('is_navigable', pa.bool_()),25 ('geometry', pa.string())26])2728# Convert to PyArrow Table with schema29table = pa.Table.from_pandas(df, schema=schema)3031# Write to Parquet32pq.write_table(table, 'my_road_data.parquet')3334print(f"Created Parquet file with {len(df)} records")
Using Python (GeoPandas)
If your data is already in a geospatial format (Shapefile, GeoJSON, etc.):
1import geopandas as gpd2import pandas as pd34# Read source data5gdf = gpd.read_file('roads.shp')67# Prepare for GEM8gem_data = pd.DataFrame({9 'id': range(1, len(gdf) + 1), # Generate unique IDs10 'is_navigable': gdf['navigable'].fillna(True), # Default to True11 'geometry': gdf.geometry.apply(lambda g: g.wkt) # Convert to WKT12})1314# Filter to LineStrings only15gem_data = gem_data[gem_data['geometry'].str.startswith('LINESTRING')]1617# Save as Parquet18gem_data.to_parquet('gem_input.parquet', index=False)1920print(f"Exported {len(gem_data)} road segments")
Using PySpark
For large datasets:
1from pyspark.sql import SparkSession2from pyspark.sql.types import StructType, StructField, LongType, BooleanType, StringType34# Initialize Spark5spark = SparkSession.builder.appName("GEM Data Prep").getOrCreate()67# Define schema8schema = StructType([9 StructField("id", LongType(), False),10 StructField("is_navigable", BooleanType(), False),11 StructField("geometry", StringType(), False)12])1314# Read your source data15source_df = spark.read.format("your_format").load("your_data")1617# Transform to GEM schema18gem_df = source_df.select(19 source_df["road_id"].alias("id"),20 source_df["navigable"].alias("is_navigable"),21 source_df["wkt_geometry"].alias("geometry")22)2324# Write as Parquet25gem_df.write.parquet("gem_input.parquet")
Data validation
Always validate your data before uploading to GEM.
Python validation script
1import pandas as pd2import re34def validate_gem_data(filepath):5 """Validate a Parquet file for GEM compatibility."""67 print(f"Validating: {filepath}")8 errors = []9 warnings = []1011 # Read the file12 try:13 df = pd.read_parquet(filepath)14 except Exception as e:15 return [f"Cannot read file: {e}"], []1617 print(f"Total records: {len(df)}")1819 # Check required columns20 required_cols = ['id', 'is_navigable', 'geometry']21 missing_cols = [c for c in required_cols if c not in df.columns]22 if missing_cols:23 errors.append(f"Missing required columns: {missing_cols}")24 return errors, warnings2526 # Check for null values27 for col in required_cols:28 null_count = df[col].isnull().sum()29 if null_count > 0:30 errors.append(f"Column '{col}' has {null_count} null values")3132 # Check ID uniqueness33 duplicate_ids = df['id'].duplicated().sum()34 if duplicate_ids > 0:35 errors.append(f"Found {duplicate_ids} duplicate IDs")3637 # Check data types38 if not pd.api.types.is_integer_dtype(df['id']):39 errors.append(f"Column 'id' should be integer, got {df['id'].dtype}")4041 if not pd.api.types.is_bool_dtype(df['is_navigable']):42 errors.append(f"Column 'is_navigable' should be boolean, got {df['is_navigable'].dtype}")4344 # Validate geometries45 linestring_pattern = r'^LINESTRING\s*\([^)]+\)$'46 invalid_geom = 047 for idx, geom in df['geometry'].items():48 if not isinstance(geom, str):49 invalid_geom += 150 elif not re.match(linestring_pattern, geom.strip(), re.IGNORECASE):51 invalid_geom += 15253 if invalid_geom > 0:54 errors.append(f"Found {invalid_geom} invalid geometries (must be WKT LINESTRING)")5556 # Check for empty geometries57 empty_geom = df['geometry'].str.contains(r'LINESTRING\s*\(\s*\)', case=False, regex=True).sum()58 if empty_geom > 0:59 warnings.append(f"Found {empty_geom} empty geometries")6061 # Summary62 print(f"\nValidation Results:")63 print(f" Errors: {len(errors)}")64 print(f" Warnings: {len(warnings)}")6566 if errors:67 print("\nErrors:")68 for e in errors:69 print(f" ❌ {e}")7071 if warnings:72 print("\nWarnings:")73 for w in warnings:74 print(f" ⚠️ {w}")7576 if not errors:77 print("\n✅ File is valid for GEM!")7879 return errors, warnings8081# Usage82errors, 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:
1from shapely import wkt2from shapely.geometry import LineString34def fix_geometry(geom):5 """Convert various geometry formats to WKT LineString."""6 try:7 # If it's already a valid WKT string8 parsed = wkt.loads(geom)9 if isinstance(parsed, LineString):10 return geom11 else:12 return None # Not a LineString13 except:14 return None1516df['geometry'] = df['geometry'].apply(fix_geometry)17df = df.dropna(subset=['geometry'])
Issue 2: Duplicate IDs
Problem: Multiple records share the same ID.
Solution:
1# Option 1: Keep first occurrence2df = df.drop_duplicates(subset=['id'], keep='first')34# Option 2: Regenerate IDs5df['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:
1import geopandas as gpd2from shapely import wkt34# Read and reproject5gdf = gpd.read_file('roads.shp')6gdf = gdf.to_crs('EPSG:4326') # Convert to WGS8478# Extract WKT9df['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}.parquet
Examples:
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:
1import pandas as pd23# Sample Amsterdam road segments4sample_data = {5 'id': [1, 2, 3, 4, 5],6 'is_navigable': [True, True, True, True, False],7 'geometry': [8 'LINESTRING (4.8952 52.3702, 4.8960 52.3710)',9 'LINESTRING (4.8960 52.3710, 4.8975 52.3725)',10 'LINESTRING (4.8975 52.3725, 4.8990 52.3740)',11 'LINESTRING (4.8990 52.3740, 4.9005 52.3755)',12 'LINESTRING (4.9005 52.3755, 4.9020 52.3770)'13 ]14}1516df = pd.DataFrame(sample_data)17df.to_parquet('sample_gem_input.parquet', index=False)18print("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.
1Parquet traces ──────────────────────────────┐2 ├─→ prepare → match → pack → results.parquet3CSV 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
1root2 |-- id: string (nullable = true)3 |-- points: array (nullable = true)4 | |-- element: struct (containsNull = true)5 | | |-- coord: struct (nullable = true)6 | | | |-- x: double (nullable = false) # longitude (WGS84)7 | | | |-- y: double (nullable = false) # latitude (WGS84)8 | | |-- heading: double (nullable = false) # degrees, 0–3609 | | |-- lane: long (nullable = true) # optional pre-assigned lane id10 | | |-- timestamp: long (nullable = false) # ms or sequential index11 | | |-- velocity: double (nullable = false) # m/s, 0.0 if unknown
Field 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:
1from pyspark.sql import SparkSession2from pyspark.sql.types import (3 StructType, StructField, StringType, DoubleType, LongType, ArrayType4)56spark = SparkSession.builder.appName("GEM Trace Prep").getOrCreate()78coord = StructType([9 StructField("x", DoubleType(), False), # longitude10 StructField("y", DoubleType(), False), # latitude11])1213trace_point = StructType([14 StructField("coord", coord, True),15 StructField("heading", DoubleType(), False),16 StructField("lane", LongType(), True),17 StructField("timestamp", LongType(), False),18 StructField("velocity", DoubleType(), False),19])2021trace_schema = StructType([22 StructField("id", StringType(), True),23 StructField("points", ArrayType(trace_point, True), True),24])2526rows = [27 ("trace-001", [28 {"coord": {"x": 4.8952, "y": 52.3702}, "heading": 34.2, "lane": None, "timestamp": 0, "velocity": 0.0},29 {"coord": {"x": 4.8960, "y": 52.3710}, "heading": 34.2, "lane": None, "timestamp": 1, "velocity": 0.0},30 ]),31]3233spark.createDataFrame(rows, trace_schema).write.mode("overwrite").parquet("traces.parquet")
Note: pandas + PyArrow can also write this file, but you must build
pointsas a list of structs andcoordas a nested struct usingpyarrow.struct(...)/pyarrow.list_(...)types — a flat table will be rejected.
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:
1LINESTRING (145.18156 -37.87340, 145.18092 -37.87356)2LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)3POINT (4.8952 52.3702)
Creating CSV files
Road profile CSV:
1import csv23# Trace polylines as WKT LINESTRINGs4traces = [5 ("trace-001", "LINESTRING (4.8952 52.3702, 4.8960 52.3710, 4.8975 52.3725)"),6 ("trace-002", "LINESTRING (4.8990 52.3740, 4.9005 52.3755, 4.9020 52.3770)"),7]89with open("road_profile.csv", "w", newline="") as f:10 writer = csv.writer(f)11 writer.writerow(["uuid", "shape"])12 writer.writerows(traces)1314print(f"Wrote {len(traces)} traces")
Road events CSV:
1import csv23# Single-point events: (uuid, heading, WKT POINT)4events = [5 ("event-001", 92.5, "POINT (4.8952 52.3702)"),6 ("event-002", 180.0, "POINT (4.8990 52.3740)"),7]89with open("road_events.csv", "w", newline="") as f:10 writer = csv.writer(f)11 writer.writerow(["uuid", "heading", "coordinate"])12 writer.writerows(events)1314print(f"Wrote {len(events)} events")
Converting geospatial data to a road profile CSV:
1import geopandas as gpd2import pandas as pd34# Read and reproject to WGS845gdf = gpd.read_file("roads.shp").to_crs("EPSG:4326")67# Keep LineStrings only and export the road profile columns8profile = pd.DataFrame({9 "uuid": [f"trace-{i}" for i in range(len(gdf))],10 "shape": gdf.geometry.apply(lambda g: g.wkt),11})12profile = profile[profile["shape"].str.upper().str.startswith("LINESTRING")]1314profile.to_csv("road_profile.csv", index=False)15print(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:
1from pyspark.sql import functions as F23df = spark.read.parquet("traces.parquet")4df.printSchema() # Compare against the Parquet schema above56assert "id" in df.columns and "points" in df.columns, "Missing id/points columns"78problems = df.filter(9 F.col("id").isNull() | (F.size("points") == 0)10).count()11print("OK" if problems == 0 else f"{problems} traces with null id or no points")
Validating a CSV file
1import csv2import re34LINESTRING_RE = re.compile(r"^LINESTRING\s*\([^)]+\)$", re.IGNORECASE)5POINT_RE = re.compile(r"^POINT\s*\([^)]+\)$", re.IGNORECASE)67def validate_traces(filepath):8 """Validate a road profile or road events CSV for GEM lane matching."""9 errors, warnings = [], []1011 with open(filepath, newline="") as f:12 reader = csv.DictReader(f)13 columns = set(reader.fieldnames or [])1415 # Detect format from headers16 if {"uuid", "shape"}.issubset(columns):17 fmt, geom_col, geom_re = "road_profile", "shape", LINESTRING_RE18 elif {"uuid", "heading", "coordinate"}.issubset(columns):19 fmt, geom_col, geom_re = "road_events", "coordinate", POINT_RE20 else:21 return [f"Unrecognized columns: {sorted(columns)}"], []2223 print(f"Detected format: {fmt}")2425 seen_ids = set()26 for line_no, row in enumerate(reader, start=2):27 uuid = (row.get("uuid") or "").strip()28 if not uuid:29 errors.append(f"Line {line_no}: missing uuid")30 elif uuid in seen_ids:31 errors.append(f"Line {line_no}: duplicate uuid '{uuid}'")32 else:33 seen_ids.add(uuid)3435 geom = (row.get(geom_col) or "").strip()36 if not geom_re.match(geom):37 errors.append(f"Line {line_no}: invalid {geom_col} geometry")3839 if fmt == "road_events":40 heading = (row.get("heading") or "").strip()41 try:42 value = float(heading)43 if not 0.0 <= value < 360.0:44 warnings.append(f"Line {line_no}: heading {value} outside [0, 360)")45 except ValueError:46 errors.append(f"Line {line_no}: heading '{heading}' is not a number")4748 print(f"\nErrors: {len(errors)} Warnings: {len(warnings)}")49 for error in errors:50 print(f" ❌ {error}")51 for warning in warnings:52 print(f" ⚠️ {warning}")53 if not errors:54 print("\n✅ File is valid for GEM lane matching!")5556 return errors, warnings5758# Usage59errors, 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:
1from shapely import wkt2from shapely.geometry import LineString, Point34def is_valid(geom, expected):5 try:6 return isinstance(wkt.loads(geom), expected)7 except Exception:8 return False910# Road profile11df = df[df["shape"].apply(lambda g: is_valid(g, LineString))]12# Road events13df = 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:
1import geopandas as gpd23gdf = gpd.read_file("roads.shp").to_crs("EPSG:4326") # Reproject to WGS844df["shape"] = gdf.geometry.apply(lambda g: g.wkt) # lon lat order
Best 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:
1root2 |-- roads: array (nullable = true)3 | |-- element: struct (containsNull = true)4 | | |-- endConnectorId: string (nullable = true)5 | | |-- lane: struct (nullable = true)6 | | | |-- confidence: double (nullable = false)7 | | | |-- endOffset: double (nullable = true)8 | | | |-- laneId: string (nullable = true)9 | | | |-- startOffset: double (nullable = true)10 | | | |-- wkt: string (nullable = true)11 | | |-- roadAreaId: string (nullable = true)12 | | |-- startConnectorId: string (nullable = true)13 |-- traceId: string (nullable = true)
Example (one match, shown as JSON):
1{2 "traceId": "trace-001",3 "roads": [4 {5 "roadAreaId": "12345:678:90",6 "startConnectorId": "12345:678:11",7 "endConnectorId": "12345:678:12",8 "lane": {9 "laneId": "12345:678:42",10 "wkt": "LINESTRING (4.8952 52.3702, 4.8960 52.3710)",11 "confidence": 100.0,12 "startOffset": 0.0,13 "endOffset": 37.514 }15 }16 ]17}
Note: The
lanefield isnullwhen only road-level matching was possible. When no route is found for a trace,roadsis an empty list.
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