Build a truck-safe route planner
If you've ever driven a semi truck under a bridge marked 3.8 meters and felt the trailer brush the underside, you understand why this matters. Commercial truck routing is a different problem from car routing. Low bridges, weight-restricted roads, and roads that ban trucks entirely -- ignore any of these and you've got a stuck vehicle blocking traffic, or worse.
We're going to build a Python script that plans a truck-safe route between two cities. It finds the base route, checks for low bridges and weight restrictions along the corridor, warns about hazards, and locates truck stops where the driver can rest. The whole thing talks to Plaza's API and runs from the command line.
What we're using
- Routing to get a base driving route
- Overpass QL to find bridges with height restrictions along the corridor
- Cross-feature queries to search along the route for truck stops
- Geocoding to let users type city names instead of coordinates
The full script
Save this as truck_route.py and run it with python truck_route.py "Memphis, TN" "Nashville, TN".
#!/usr/bin/env python3
"""Plan a truck-safe route between two cities."""
import sys
import json
import requests
API_KEY = "pk_live_YOUR_KEY"
BASE = "https://plaza.fyi/api/v1"
HEADERS = {"x-api-key": API_KEY}
# Truck specs -- adjust for your rig
TRUCK_HEIGHT_M = 4.11 # standard semi trailer
TRUCK_WEIGHT_T = 36 # 36 metric tons loaded
def plaza_get(path, params=None):
resp = requests.get(f"{BASE}{path}", params=params, headers=HEADERS)
resp.raise_for_status()
return resp.json()
def plaza_post(path, body):
resp = requests.post(
f"{BASE}{path}", json=body,
headers={**HEADERS, "Content-Type": "application/json"},
)
resp.raise_for_status()
return resp.json()
def geocode(place):
data = plaza_get("/geocode", {"q": place, "limit": 1})
if not data.get("results"):
print(f"Could not find: {place}")
sys.exit(1)
r = data["results"][0]
print(f" {place} -> {r['display_name']} ({r['lat']}, {r['lng']})")
return {"lat": r["lat"], "lng": r["lng"]}
def get_route(origin, dest):
return plaza_post("/route", {
"origin": origin,
"destination": dest,
"mode": "auto",
})
def find_low_bridges(route_coords, buffer_km=2):
"""Find bridges with maxheight below our truck height along the route."""
# Sample points along the route to build an Overpass query corridor
# Pick points every ~20km to keep the query manageable
sample_points = route_coords[::max(1, len(route_coords) // 15)]
all_bridges = []
for lng, lat in sample_points:
data = plaza_post("/overpass", {
"data": (
f"[out:json];"
f"way[bridge=yes][maxheight](around:2000,{lat},{lng});"
f"out center;"
),
})
for f in data.get("features", []):
tags = f.get("properties", {}).get("tags", {})
maxheight = tags.get("maxheight", "")
# Parse maxheight -- OSM stores it as a string like "3.8" or "12'6\""
height_m = parse_height(maxheight)
if height_m and height_m < TRUCK_HEIGHT_M:
name = tags.get("name", "Unnamed bridge")
coords = f["geometry"]["coordinates"]
# Deduplicate by OSM ID
osm_id = f["properties"].get("osm_id")
if not any(b["osm_id"] == osm_id for b in all_bridges):
all_bridges.append({
"name": name,
"maxheight": maxheight,
"height_m": height_m,
"osm_id": osm_id,
"coordinates": coords,
})
return all_bridges
def find_weight_restrictions(route_coords):
"""Find roads with maxweight below our truck weight along the route."""
sample_points = route_coords[::max(1, len(route_coords) // 10)]
restrictions = []
for lng, lat in sample_points:
data = plaza_post("/overpass", {
"data": (
f"[out:json];"
f"way[maxweight](around:1000,{lat},{lng});"
f"out center;"
),
})
for f in data.get("features", []):
tags = f.get("properties", {}).get("tags", {})
maxweight = tags.get("maxweight", "")
try:
weight_t = float(maxweight)
except ValueError:
continue
if weight_t < TRUCK_WEIGHT_T:
osm_id = f["properties"].get("osm_id")
if not any(r["osm_id"] == osm_id for r in restrictions):
restrictions.append({
"road": tags.get("name", "Unnamed road"),
"maxweight": maxweight,
"osm_id": osm_id,
})
return restrictions
def find_truck_stops(origin, dest):
"""Find truck stops along the route using search_along."""
return plaza_post("/route", {
"origin": origin,
"destination": dest,
"mode": "auto",
"search_along": {
"tags": "amenity=fuel,hgv=yes",
"buffer_m": 5000,
},
})
def parse_height(height_str):
"""Parse OSM maxheight values. Returns meters or None."""
if not height_str:
return None
# Handle "3.8", "3.8 m"
cleaned = height_str.strip().rstrip("m").strip()
try:
return float(cleaned)
except ValueError:
pass
# Handle imperial: 12'6" or 12' 6"
if "'" in height_str:
parts = height_str.replace('"', '').split("'")
try:
feet = float(parts[0].strip())
inches = float(parts[1].strip()) if len(parts) > 1 and parts[1].strip() else 0
return round(feet * 0.3048 + inches * 0.0254, 2)
except ValueError:
pass
return None
def format_duration(seconds):
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def main():
if len(sys.argv) != 3:
print("Usage: python truck_route.py 'Origin City' 'Destination City'")
sys.exit(1)
origin_name, dest_name = sys.argv[1], sys.argv[2]
print(f"\nPlanning truck route: {origin_name} -> {dest_name}")
print(f"Truck specs: {TRUCK_HEIGHT_M}m height, {TRUCK_WEIGHT_T}t weight\n")
# Geocode both cities
print("Geocoding...")
origin = geocode(origin_name)
dest = geocode(dest_name)
# Get the base route
print("\nCalculating route...")
route = get_route(origin, dest)
props = route["properties"]
print(f" Distance: {props['distance_m'] / 1000:.1f} km")
print(f" Duration: {format_duration(props['duration_s'])}")
route_coords = route["geometry"]["coordinates"]
# Check for low bridges
print(f"\nScanning for bridges below {TRUCK_HEIGHT_M}m...")
bridges = find_low_bridges(route_coords)
if bridges:
print(f" WARNING: Found {len(bridges)} low bridge(s):")
for b in sorted(bridges, key=lambda x: x["height_m"]):
print(f" {b['name']}: {b['maxheight']} ({b['height_m']}m)")
else:
print(" No low bridges found along this corridor.")
# Check weight restrictions
print(f"\nScanning for weight limits below {TRUCK_WEIGHT_T}t...")
weight = find_weight_restrictions(route_coords)
if weight:
print(f" WARNING: Found {len(weight)} weight-restricted road(s):")
for w in weight:
print(f" {w['road']}: max {w['maxweight']}t")
else:
print(" No weight restrictions found along this corridor.")
# Find truck stops
print("\nFinding truck stops along route...")
stops_result = find_truck_stops(origin, dest)
stops = stops_result.get("search_results", {}).get("features", [])
if stops:
print(f" Found {len(stops)} truck stop(s):")
for s in stops[:8]:
name = s["properties"].get("tags", {}).get("name", "Unnamed stop")
brand = s["properties"].get("tags", {}).get("brand", "")
label = f"{name} ({brand})" if brand else name
print(f" {label}")
else:
print(" No truck stops found. Check fuel availability before departing.")
# Summary
print("\n" + "=" * 50)
if bridges or weight:
print("ROUTE HAS HAZARDS. Review warnings above before departing.")
else:
print("Route looks clear for your truck specs.")
print("=" * 50)
if __name__ == "__main__":
main()
Walking through it
Geocoding the cities
The driver types city names, not coordinates. We geocode both and print the resolved addresses so the driver can confirm we found the right place. "Memphis" could be Memphis, TN or Memphis, TX -- confirming the resolved name catches that early.
Getting the base route
plaza_post("/route", {
"origin": origin,
"destination": dest,
"mode": "auto",
})
This gives us a driving route with distance and duration. The route geometry -- a list of [lng, lat] coordinate pairs -- is what we use to scan for hazards.
Scanning for low bridges
This is the part that actually matters. We sample points along the route every ~20km and, for each point, query for bridges within 2km that have a maxheight tag:
way[bridge=yes][maxheight](around:2000,LAT,LNG);
OSM contributors tag bridges with their clearance height. The data isn't perfect -- not every bridge has a maxheight tag -- but coverage on major highways is pretty good, especially in the US and Europe. We parse the height (handling both metric "3.8" and imperial "12'6"" formats) and flag anything below our truck's height.
Weight restrictions
Same approach, different tag. maxweight on a road means vehicles above that weight aren't supposed to use it. We scan the corridor and flag any restrictions below our loaded weight.
Truck stops along the route
Here we use Plaza's search_along cross-feature query:
plaza_post("/route", {
"origin": origin,
"destination": dest,
"mode": "auto",
"search_along": {"tags": "amenity=fuel,hgv=yes", "buffer_m": 5000},
})
This calculates the route and simultaneously searches for fuel stops tagged hgv=yes (heavy goods vehicle) within 5km of the route line. One API call does both the routing and the spatial search. Without search_along, you'd need to calculate the route, then manually buffer the geometry, then run a separate search inside that buffer.
Limitations to know about
This script scans a corridor around the route, not the route itself. A low bridge flagged 1.5km off the highway might not be on your actual path. For production use, you'd want to check whether each hazard actually intersects with the route geometry (a PostGIS ST_Intersects call, or some client-side line intersection math).
OSM bridge tagging coverage varies by region. In the US, most interstate bridges are tagged. Rural back roads are spottier. This tool is a supplement to, not a replacement for, the trucker's atlas and local knowledge.
Where to take this next
Export the route coordinates to GPX so they can be loaded into a truck GPS unit. If the primary route has hazards, try routing around them by adding waypoints that avoid the problem bridges.
US DOT hours-of-service rules require breaks at specific intervals. You could calculate where along the route the driver will hit their limit and highlight truck stops near those points.
Weather is another obvious one. High wind advisories matter a lot more when you're driving a high-profile trailer. Pull forecasts for the route corridor and flag anything the driver should know about.