Map a 5K Running Loop from Home
Build a Python tool that maps and exports a 5K running loop from your door.
Introduction
30 Second Summary
A familiar run can become repetitive when every outing follows the same streets. Planning a fresh route with the right distance can take longer than the run itself.
In this project, you will build a Python route planner that turns your street address into a five kilometre running loop. It uses Nominatim with the Overpass API to search OpenStreetMap data before exporting the result as GeoJSON and GPX files.
What You'll Build
Picture entering your address and loading a browser map filled with nearby streets. Your chosen loop stands out on top with its distance measured close to five kilometres.
By the end of this project, you'll have:
- An address location check that converts your street address into coordinates you can verify.
- A browser map where you can inspect the real streets and footpaths surrounding your front door.
- A portable running loop that opens in a map viewer or running app with the same closed shape shown in your browser.
- Secret Mission: Change the target distance to 10 kilometres and compare how much wider the new loop reaches.
Are there any prerequisites?
Python must have the requests library installed.
A browser and a street address are the only other prerequisites.
Before We Start
Your route generator needs a reliable local workspace before it can turn an address into a map. A missing tool would stop the first request from running.
This step prepares Python 3, Requests, Visual Studio Code, and a browser. You will also choose the address and distance that drive the route search.
In this step, get ready to:
- Confirm Python 3 is available from the terminal.
- Install Requests for the HTTP calls.
- Prepare the running-loop workspace with the route inputs.
Confirm Python 3
Python runs the route-building script from your terminal. Checking the interpreter first gives every later command a dependable starting point.
macOS
- Press Cmd+Space to open Spotlight.
- Type Terminal.
- Press Return to open Terminal.
- Check the installed Python version by running:
python3 --version
What does this command do?
The command asks the Python 3 executable to print its version. A version line confirms that your terminal can reach the interpreter.
Windows
- Press the Windows key to open search.
- Type Windows PowerShell.
- Select Windows PowerShell.
- Select Open.
- Check the installed Python version by running:
py -3 --version
What does this command do?
The Python launcher selects an installed Python 3 runtime. The version line confirms that the launcher can reach it.
✔️ Python is ready
That is your first setup check complete. Your terminal can now run Python scripts for the route generator.
ⓧ Command not found
Your terminal cannot reach Python yet. Install the latest stable release from the official source for your operating system.
macOS
- Open the official Python releases for macOS page in your browser.
- Select Download macOS installer for the latest stable release.
- Open the downloaded installer package.
- Complete the installer with its default options.
- Close Terminal after the installation finishes.
- Open Terminal again through Spotlight.
- Confirm the installation by running:
python3 --version
What should I see?
You should now see a version beginning with 3. This confirms the new interpreter is available from Terminal.
Windows
- Open the official Python releases for Windows page in your browser.
- Select Download Installer (MSIX) for the latest Python install manager.
- Open the downloaded installer.
- Select Install.
- Close Windows PowerShell after the installation finishes.
- Open Windows PowerShell again through search.
- Confirm the installation by running:
py -3 --version
What should I see?
You should now see a version beginning with 3. This confirms the Python launcher can reach the new runtime.
Still missing Python?
Close every terminal window after installing Python. Open a fresh terminal so it can load the updated command path.
If the command still fails, confirm that the installer completed successfully. Help me troubleshoot why Python is still unavailable after installation.
Prepare your coding tools
Requests is the HTTP library that sends the geocoding and map-data calls. Installing it now keeps the first script focused on the address response.
The first package install can take a minute while Python downloads its files. A short pause in the terminal is normal.
macOS
- Install Requests through your Python 3 interpreter by running:
python3 -m pip install requests
What does this command do?
Python runs its matching package installer. That connection keeps Requests attached to the same interpreter you checked earlier.
The final terminal lines confirm that Requests is installed or already available.
Requests did not install?
Confirm the terminal still prints a Python 3 version. A package installation failure can also come from a blocked network connection.
Try the command once more after checking your connection. Help me diagnose my Requests installation.
Windows
- Install Requests through your Python 3 runtime by running:
py -3 -m pip install requests
What does this command do?
The Python launcher selects Python 3 before running its matching package installer. That connection prevents Requests from landing under a different interpreter.
The final terminal lines confirm that Requests is installed or already available.
Requests did not install?
Confirm the terminal still prints a Python 3 version. A package installation failure can also come from a blocked network connection.
Try the command once more after checking your connection. Help me diagnose my Requests installation.
Next, check whether Visual Studio Code is already available. It gives the Python script and HTML map one shared workspace.
macOS
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code.
- Press Return.
Visual Studio Code opens if it is installed.
Windows
- Press the Windows key to open search.
- Type Visual Studio Code.
- Press Enter.
Visual Studio Code opens if it is installed.
✔️ Visual Studio Code opens
Your code editor is ready. Keep it open for the workspace setup.
ⓧ Visual Studio Code is missing
The download and installation can take a few minutes. That wait gives your computer time to place the editor in its standard application location.
macOS
- Open the official Visual Studio Code download page in your browser.
- Download the .dmg build for your Mac.
- Open the downloaded .dmg file.
- Drag Visual Studio Code.app into the Applications folder.
- Open Visual Studio Code through Spotlight.
Windows
- Open the official Visual Studio Code download page in your browser.
- Download the User Installer for your Windows architecture.
- Open the downloaded installer.
- Complete the setup prompts.
- Open Visual Studio Code through Windows search.
Editor still unavailable?
Search for the full name Visual Studio Code after the installer finishes. Restart your computer if Windows or macOS has not refreshed its application search yet.
Help me finish installing Visual Studio Code.
Create and verify the workspace
One project folder keeps the Python script, cached map data, browser map, GeoJSON export, and GPX export together. Visual Studio Code can open its integrated terminal directly inside that folder.
macOS
- Click an empty area of your desktop.
- Select File from the Finder menu bar.
- Select New Folder.
- Type running-loop.
- Press Return.
You will see the new running-loop folder on your desktop.
- Switch back to Visual Studio Code.
- Select File from the menu bar.
- Select Open....
- Choose the running-loop folder from your desktop.
- Select Open.
- Select View from the menu bar.
- Select Terminal.
The integrated terminal opens at the running-loop folder.
Windows
- Press Windows+E to open File Explorer.
- Select Desktop in the navigation pane.
- Select New from the toolbar.
- Select Folder.
- Type running-loop.
- Press Enter.
You will see the new running-loop folder on your desktop.
- Switch back to Visual Studio Code.
- Select File from the menu bar.
- Select Open Folder....
- Choose the running-loop folder from your desktop.
- Select Select Folder.
- Select View from the menu bar.
- Select Terminal.
The integrated terminal opens at the running-loop folder.
- Choose the home street address that your running loop should start from.
- Record your address here: your chosen home street address.
- Record the route target as TARGET_DISTANCE_METRES = 5000.
Keep Your Address Private
Your exact home address stays on your computer during this project. Keep it out of screenshots or shared code.
Before you run the final check, do you expect the integrated terminal to find both Python and Requests?
macOS
- Verify Python and Requests from the running-loop terminal by running:
python3 --version
python3 -m pip install requests
What does this check prove?
- The first command confirms that Python 3 is available inside the project workspace.
- The second command confirms that Requests is installed for that interpreter.
You will see a Python version beginning with 3. The package check confirms that Requests is installed or already available.
Final check failed?
Confirm that the terminal panel belongs to the Visual Studio Code window containing running-loop. Restart Visual Studio Code if it was open while Python was installed.
Help me fix my final macOS setup check.
Windows
- Verify Python and Requests from the running-loop terminal by running:
py -3 --version
py -3 -m pip install requests
What does this check prove?
- The first command confirms that Python 3 is available inside the project workspace.
- The second command confirms that Requests is installed for that runtime.
You will see a Python version beginning with 3. The package check confirms that Requests is installed or already available.
Final check failed?
Confirm that the terminal panel belongs to the Visual Studio Code window containing running-loop. Restart Visual Studio Code if it was open while Python was installed.
Help me fix my final Windows setup check.
Your Setup Is Ready
Your running-loop folder is configured with Python 3 plus Requests.
Visual Studio Code holds the folder with an integrated terminal. Your browser is available for the local HTML map.
Your address is stored as ADDRESS. Your target is stored as TARGET_DISTANCE_METRES = 5000.
Your workspace is ready for real map data. Next, you will send your address to Nominatim and inspect the coordinates it returns.
Geocode Your Starting Address
A running route needs a precise point before it can follow nearby streets. A written address can match several places.
In this step, your Python script uses Nominatim to search OpenStreetMap for your chosen address. The printed place details let you confirm that the coordinates land on the right street.
In this step, get ready to:
- Define your address plus the target distance in running_loop.py.
- Send an identified search request to Nominatim.
- Store the top match as START_LAT plus START_LON.
Send the address to Nominatim
Geocoding converts a readable address into latitude plus longitude. Nominatim returns possible matches in ranked order.
- Create running_loop.py at the top level of the project folder by using your editor's new-file control.
- Add the geocoding request by copying this code into running_loop.py:
import requests
# Keep the inputs for this route in one place.
ADDRESS = "[[ADDRESS=\"your full street address\"]]"
TARGET_DISTANCE_METRES = 5000
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
# Identify this learning project to the public service.
headers = {"User-Agent": "nextwork-running-loop/1.0"}
params = {
"q": ADDRESS,
"format": "jsonv2",
"limit": 5,
}
# Ask Nominatim for matching places and decode the JSON response.
response = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=30)
response.raise_for_status()
results = response.json()
# Print the candidates so the top match can be checked.
print(f"Results returned: {len(results)}")
for index, result in enumerate(results, start=1):
print(f"{index}. {result['display_name']}")
What does this code do?
- The ADDRESS value holds the place that Nominatim searches for.
- The TARGET_DISTANCE_METRES value preserves the route goal of 5000 metres.
- The User-Agent header identifies this project when it uses the public service.
- The params dictionary sends the address through q.
- The jsonv2 format gives the script structured results that it can read.
- The final loop prints each returned place name for comparison.
- Save running_loop.py.
Before you run the script, which part of your address do you expect Nominatim to use for the first match?
- Return to the terminal from earlier.
- Ask Nominatim for matching places by running this command:
python3 running_loop.py
What should I see?
You should see a result count followed by up to five numbered place names. The first place should describe your street or its immediate area.
Good progress. Your script can now turn the address into real place candidates.
No useful address results?
- Check that ADDRESS includes a house number plus a street name.
- Add the city plus country when several places share the same street name.
- Wait at least one second before sending the request again.
Ask for help diagnosing the search with Why did Nominatim return no useful result for my street address?.
Choose the route start
The first result is Nominatim's top-ranked match. Converting its text coordinates to numbers creates values that later graph calculations can use.
- In running_loop.py, find this line:
print(f"{index}. {result['display_name']}")
Where does this line fit?
This is the final line of the loop that prints each candidate. The coordinate selection belongs below the complete loop so it runs once after every candidate has been displayed.
- Place your cursor on a new line below the complete results loop.
- Add the selection block at that location by copying this code:
# Stop with a clear message when the address has no matches.
if not results:
raise ValueError("No geocoding results found. Add more detail to ADDRESS.")
# Use Nominatim's top-ranked result as the route start.
selected_result = results[0]
START_LAT = float(selected_result["lat"])
START_LON = float(selected_result["lon"])
# Show the selected place and the coordinates used by later steps.
print(f"Matched: {selected_result['display_name']}")
print(f"Latitude: {START_LAT}")
print(f"Longitude: {START_LON}")
print(f"Target distance: {TARGET_DISTANCE_METRES} metres")
How is the start selected?
- The empty-results check stops the script before it tries to select a missing place.
- The selected_result value holds the first ranked match.
- The float() conversions store both coordinates as numbers.
- The final lines print the matched place plus the values that later steps use.
- Save running_loop.py.
Before you run the finished script, do you expect the first match to name the same street as your chosen address?
- Return to the terminal from earlier.
- Confirm the selected starting point by running this command:
python3 running_loop.py
What should I see?
You should see the candidate list first. Below it, you should see lines beginning with Matched, Latitude, Longitude, plus Target distance.
The matched place should name your street or immediate locality. Both coordinates should appear as decimal numbers.
That is the first real piece of your route locked in. START_LAT plus START_LON now anchor every map query that follows.
Coordinates point to the wrong place?
- Make ADDRESS more specific by including the city plus country.
- Check the spelling when you see No geocoding results found. Add more detail to ADDRESS..
- Run the script again after changing the address.
Get targeted help with Why does my Nominatim result point to the wrong location?.
✔️ Awesome, I've got everything!
Your saved script now prints the matched location plus its coordinates.
ⓧ I'd like to double check the full code
import requests
# Keep the inputs for this route in one place.
ADDRESS = "[[ADDRESS=\"your full street address\"]]"
TARGET_DISTANCE_METRES = 5000
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
# Identify this learning project to the public service.
headers = {"User-Agent": "nextwork-running-loop/1.0"}
params = {
"q": ADDRESS,
"format": "jsonv2",
"limit": 5,
}
# Ask Nominatim for matching places and decode the JSON response.
response = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=30)
response.raise_for_status()
results = response.json()
# Print the candidates so the top match can be checked.
print(f"Results returned: {len(results)}")
for index, result in enumerate(results, start=1):
print(f"{index}. {result['display_name']}")
# Stop with a clear message when the address has no matches.
if not results:
raise ValueError("No geocoding results found. Add more detail to ADDRESS.")
# Use Nominatim's top-ranked result as the route start.
selected_result = results[0]
START_LAT = float(selected_result["lat"])
START_LON = float(selected_result["lon"])
# Show the selected place and the coordinates used by later steps.
print(f"Matched: {selected_result['display_name']}")
print(f"Latitude: {START_LAT}")
print(f"Longitude: {START_LON}")
print(f"Target distance: {TARGET_DISTANCE_METRES} metres")
Your starting address now has a confirmed point on the map. Next, you will use the Overpass API to collect the walkable streets around it.
Download the Walkable Street Network
Your address now resolves to a confirmed point on the map. That point gives every network query a reliable centre.
Now you need the nearby streets that can carry a loop. The Overpass API reads OpenStreetMap data through a query.
The script requests highway ways within 2,000 metres of your start point. It caches the response so later steps use the same snapshot.
In this step, get ready to:
- Build an Overpass query for nearby walkable highway ways.
- Send the query from running_loop.py.
- Cache the complete JSON response in overpass_response.json.
Build the Overpass Query
An OpenStreetMap way stores references to nodes. Those nodes hold the coordinates needed to reconstruct each street or path.
- Switch back to running_loop.py in your code editor.
- Find the code that stores START_LAT plus START_LON.
- Add the following block beneath those coordinates:
import json
# Set the search area around the confirmed start point.
RADIUS_METRES = 2000
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Request walkable highway ways plus every node that shapes them.
overpass_query = f"""
[out:json];
way["highway"]["highway"!~"motorway|motorway_link|trunk|trunk_link"]["foot"!~"no"]["access"!~"private|no"](around:{RADIUS_METRES},{START_LAT},{START_LON});
(._;>;);
out body;
"""
# Download one snapshot with the identifying headers used for geocoding.
overpass_response = requests.post(OVERPASS_URL, data={"data": overpass_query}, headers=HEADERS)
overpass_response.raise_for_status()
overpass_data = overpass_response.json()
# Count each map element type before saving the cache.
elements = overpass_data["elements"]
way_count = sum(element["type"] == "way" for element in elements)
node_count = sum(element["type"] == "node" for element in elements)
print(f"Overpass returned {way_count} ways and {node_count} nodes.")
# Keep the complete response for the graph-building step.
with open("overpass_response.json", "w", encoding="utf-8") as cache_file:
json.dump(overpass_data, cache_file, indent=2)
print("Saved overpass_response.json.")
What Does This Code Do?
- The RADIUS_METRES value limits the search to 2,000 metres around your confirmed coordinates.
- The overpass_query selects highway ways while excluding major roads with no practical running access.
- The > recurse operator adds every node referenced by the selected ways.
- The requests.post() call sends the query with the identifying headers from your geocoding request.
- The counters separate ways from nodes so you can confirm both element types arrived.
- The json.dump() call saves the complete response for reuse.
- Save running_loop.py.
This download can take longer than geocoding because Overpass assembles every matching way around your point.
Before you rerun the script, do you expect the node count or the way count to be larger?
- Rerun running_loop.py with the same terminal command from the previous step.
You should see a positive way count followed by a larger positive node count. You should also see confirmation that overpass_response.json was saved.
You now have a reusable snapshot of the streets around your door. The route builder has real map data to work with.
Seeing Zero Counts or a Request Error?
- Wait 30 seconds before rerunning the script if the server reports 429.
- Replace HEADERS with the exact variable name used by your Nominatim request if Python reports that the name is undefined.
- Check that START_LAT holds the confirmed latitude if both counts are zero.
- Check that START_LON holds the confirmed longitude if both counts are zero.
Help me diagnose my Overpass download.
✔️ Awesome, I've got everything!
Your Overpass section is in place. Keep the saved script open for the cache check.
ⓧ I'd like to double check the full code
This reference shows the complete Overpass section added beneath your existing geocoding logic.
import json
# Set the search area around the confirmed start point.
RADIUS_METRES = 2000
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Request walkable highway ways plus every node that shapes them.
overpass_query = f"""
[out:json];
way["highway"]["highway"!~"motorway|motorway_link|trunk|trunk_link"]["foot"!~"no"]["access"!~"private|no"](around:{RADIUS_METRES},{START_LAT},{START_LON});
(._;>;);
out body;
"""
# Download one snapshot with the identifying headers used for geocoding.
overpass_response = requests.post(OVERPASS_URL, data={"data": overpass_query}, headers=HEADERS)
overpass_response.raise_for_status()
overpass_data = overpass_response.json()
# Count each map element type before saving the cache.
elements = overpass_data["elements"]
way_count = sum(element["type"] == "way" for element in elements)
node_count = sum(element["type"] == "node" for element in elements)
print(f"Overpass returned {way_count} ways and {node_count} nodes.")
# Keep the complete response for the graph-building step.
with open("overpass_response.json", "w", encoding="utf-8") as cache_file:
json.dump(overpass_data, cache_file, indent=2)
print("Saved overpass_response.json.")
Verify the Cached Network
The cache is a local snapshot of the downloaded network. Checking its contents confirms that the response contains both street definitions and coordinates.
- Select overpass_response.json in your code editor's file sidebar.
- Use your editor's search box to find "type": "way".
- Repeat the search for "type": "node".
- Confirm that both values appear inside the elements list.
You should find both element types throughout the file. This proves the cache contains the ways plus the nodes needed to rebuild their shapes.
Your street network is downloaded and safely cached. Next, you'll turn this snapshot into a graph. Then you'll draw its streets in the browser.
Build and Map the Street Graph
Your cached Overpass API response proves that OpenStreetMap returned real ways and nodes around your address. Those records still describe separate IDs instead of a connected running network.
This step turns those records into a bidirectional graph. Each edge records the length of one street segment in metres.
Python then exports the network as GeoJSON. A browser map makes every downloaded street visible around your address.
In this step, get ready to:
- Index the downloaded nodes by their OpenStreetMap IDs.
- Connect consecutive way nodes with metre-based graph edges.
- Export the street network as GeoJSON for an interactive map.
Build the graph from OSM data
A street graph represents intersections as nodes. Its edges record which nodes connect directly through a street or path.
Every connection goes into the adjacency list twice. This lets the later route search travel through each segment in either direction.
- In running_loop.py, add the following section after the code that saves overpass_response.json:
import math
# Read the cached map data so repeated runs use the same network
with open("overpass_response.json", encoding="utf-8") as file:
elements = json.load(file)["elements"]
nodes = {element["id"]: (element["lat"], element["lon"]) for element in elements if element["type"] == "node"}
ways = [element for element in elements if element["type"] == "way" and "highway" in element.get("tags", {})]
graph = {node_id: [] for node_id in nodes}
# Measure the great-circle distance between two OSM nodes in metres
def distance_metres(start_id, end_id):
lat1, lon1 = map(math.radians, nodes[start_id])
lat2, lon2 = map(math.radians, nodes[end_id])
delta_lat = lat2 - lat1
delta_lon = lon2 - lon1
a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2
return 2 * 6371008.8 * math.asin(math.sqrt(a))
# Connect consecutive way nodes in both travel directions
for way in ways:
for start_id, end_id in zip(way["nodes"], way["nodes"][1:]):
if start_id in nodes and end_id in nodes:
distance = distance_metres(start_id, end_id)
graph[start_id].append((end_id, distance))
graph[end_id].append((start_id, distance))
edge_count = sum(len(edges) for edges in graph.values())
print(f"Graph contains {len(nodes)} nodes and {edge_count} directed edges")
How does the graph work?
- The nodes dictionary lets the script find each coordinate from its OpenStreetMap node ID.
- The distance_metres() function uses latitude and longitude to measure each segment across the Earth’s surface.
- The graph dictionary stores neighbouring node IDs beside their segment lengths.
- The two append() calls make every connection available in both directions.
- Save running_loop.py.
Before you run the script, predict what bidirectional connections should do to the edge count.
- Build the graph by running this command:
python running_loop.py
What should you see?
You should see a graph summary with a non-zero node count. You should also see a non-zero directed edge count.
The edge count includes each usable segment in both directions. That structure gives the route search a way out of each node.
Seeing zero graph edges?
- Check that overpass_response.json remains inside the same project folder as running_loop.py.
- Check that the earlier Overpass query returned both ways and nodes.
- Confirm that the way records contain node ID sequences under nodes.
Help me debug my empty street graph.
Export the street network
The graph is useful for route searching. The browser needs geographic features whose coordinates follow each complete street or path.
A GeoJSON FeatureCollection provides that bridge. Each downloaded way becomes one LineString feature.
- In running_loop.py, add the following export section below the graph summary:
# Convert each complete way into a GeoJSON line for the browser map
network_features = []
for way in ways:
coordinates = [[nodes[node_id][1], nodes[node_id][0]] for node_id in way["nodes"] if node_id in nodes]
if len(coordinates) < 2:
continue
network_features.append({
"type": "Feature",
"properties": {"way_id": way["id"]},
"geometry": {
"type": "LineString",
"coordinates": coordinates,
},
})
network_geojson = {"type": "FeatureCollection", "features": network_features}
with open("network.geojson", "w", encoding="utf-8") as file:
json.dump(network_geojson, file)
print(f"Saved {len(network_features)} street features to network.geojson")
What does the export create?
- Each coordinates list follows one way through its ordered node IDs.
- GeoJSON stores every coordinate as longitude followed by latitude.
- The way_id property preserves the source way ID for later inspection.
- The final FeatureCollection gathers every valid LineString into network.geojson.
- Save running_loop.py.
Before you run the export, predict whether its feature count should be closer to the number of ways or the number of nodes.
- Generate network.geojson by running this command:
python running_loop.py
What did the export prove?
You should see a message confirming that street features were saved to network.geojson. The feature count should be non-zero.
That count tracks exported ways because each valid way becomes one LineString. The file now contains the complete browser-ready network.
Missing network.geojson?
- Confirm that you saved the export section inside running_loop.py.
- Check the terminal for a Python syntax error near the Feature dictionary.
- Make sure the script reports a non-zero number of ways before the export runs.
Help me fix my GeoJSON export.
✔️ Awesome, I've got everything!
Great. Double-check that you saved running_loop.py before moving to the map.
ⓧ I'd like to double check the full code
Compare the graph-building and GeoJSON export section at the end of your file with this reference.
import math
# Read the cached map data so repeated runs use the same network
with open("overpass_response.json", encoding="utf-8") as file:
elements = json.load(file)["elements"]
nodes = {element["id"]: (element["lat"], element["lon"]) for element in elements if element["type"] == "node"}
ways = [element for element in elements if element["type"] == "way" and "highway" in element.get("tags", {})]
graph = {node_id: [] for node_id in nodes}
# Measure the great-circle distance between two OSM nodes in metres
def distance_metres(start_id, end_id):
lat1, lon1 = map(math.radians, nodes[start_id])
lat2, lon2 = map(math.radians, nodes[end_id])
delta_lat = lat2 - lat1
delta_lon = lon2 - lon1
a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(delta_lon / 2) ** 2
return 2 * 6371008.8 * math.asin(math.sqrt(a))
# Connect consecutive way nodes in both travel directions
for way in ways:
for start_id, end_id in zip(way["nodes"], way["nodes"][1:]):
if start_id in nodes and end_id in nodes:
distance = distance_metres(start_id, end_id)
graph[start_id].append((end_id, distance))
graph[end_id].append((start_id, distance))
edge_count = sum(len(edges) for edges in graph.values())
print(f"Graph contains {len(nodes)} nodes and {edge_count} directed edges")
# Convert each complete way into a GeoJSON line for the browser map
network_features = []
for way in ways:
coordinates = [[nodes[node_id][1], nodes[node_id][0]] for node_id in way["nodes"] if node_id in nodes]
if len(coordinates) < 2:
continue
network_features.append({
"type": "Feature",
"properties": {"way_id": way["id"]},
"geometry": {
"type": "LineString",
"coordinates": coordinates,
},
})
network_geojson = {"type": "FeatureCollection", "features": network_features}
with open("network.geojson", "w", encoding="utf-8") as file:
json.dump(network_geojson, file)
print(f"Saved {len(network_features)} street features to network.geojson")
Map every street and path
The exported lines become easier to inspect when they sit over a familiar map. Leaflet provides the interactive panning and zooming controls.
The page uses Leaflet version 1.9.4 to load network.geojson. It fits the map view around the complete downloaded network.
- In your code editor’s file sidebar, create map.html inside the open project folder.
You should now see map.html beside running_loop.py in the file sidebar.
- Paste the following page into map.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>5K Street Network</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
/* Fill the browser window with the interactive network map */
html, body, #map { height: 100%; margin: 0; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
// Create the interactive map over OpenStreetMap tiles
const map = L.map("map")
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map)
// Draw every exported street and fit the view around the network
fetch("network.geojson")
.then((response) => response.json())
.then((data) => {
const networkLayer = L.geoJSON(data, { style: { color: "#475569", weight: 2 } }).addTo(map)
map.fitBounds(networkLayer.getBounds())
})
</script>
</body>
</html>
How does the map page work?
- The map element fills the browser window with an interactive map.
- The tile layer supplies geographic context beneath your downloaded street lines.
- The fetch() call reads the FeatureCollection from network.geojson.
- The fitBounds() call frames the complete network automatically.
- Save map.html.
✔️ Awesome, I've got everything!
Your map page is ready. Keep map.html in the same folder as network.geojson.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>5K Street Network</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<style>
/* Fill the browser window with the interactive network map */
html, body, #map { height: 100%; margin: 0; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
// Create the interactive map over OpenStreetMap tiles
const map = L.map("map")
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map)
// Draw every exported street and fit the view around the network
fetch("network.geojson")
.then((response) => response.json())
.then((data) => {
const networkLayer = L.geoJSON(data, { style: { color: "#475569", weight: 2 } }).addTo(map)
map.fitBounds(networkLayer.getBounds())
})
</script>
</body>
</html>
- Return to the terminal from earlier.
- Serve the project folder locally by running this command:
python -m http.server
Why use a local server?
The server makes network.geojson available to the page through a normal web request. It serves the open project folder on port 8000 by default.
Keep this terminal running while you inspect the map. The server needs to remain active for the GeoJSON request.
Before you open the page, predict whether the network shape will match the streets surrounding your chosen address.
- Open http://localhost:8000/map.html in your web browser.
You should see an interactive map framed around your address. Every downloaded street or path should appear as a line over the surrounding map tiles.
Seeing a blank map?
- Confirm that network.geojson sits beside map.html in the project folder.
- Confirm that the local server is still running in the terminal.
- Refresh the browser after regenerating network.geojson.
Help me debug my blank street network map.
That is a major visual win. Your downloaded map data now forms a measurable graph that you can inspect in the browser.
Next, you will search this graph for a closed loop that starts near your front door and lands close to five kilometres.
Find a 5K Loop
The map from the last step proves that the downloaded streets connect around your address. Your script now has a street graph that it can search for possible running routes.
A useful route needs to begin at the graph node closest to your front door. In this step, you will search for paths that return to that node without revisiting other nodes. Your script will keep the closed loop closest to 5000 metres.
In this step, get ready to:
- Match your geocoded address to the nearest node in the street graph.
- Search non-repeating paths that close back at the starting node.
- Print the distance of the closed loop closest to 5 kilometres.
Anchor the search at your door
Your address coordinates rarely sit directly on a graph node. The nearest node gives every candidate loop one consistent starting point.
- Add the nearest-node calculation at the end of running_loop.py by pasting this code:
# Match the front door to the nearest node in the walkable graph
START_NODE = min(
graph,
key=lambda node_id: distance_metres(
START_LAT,
START_LON,
node_coordinates[node_id][0],
node_coordinates[node_id][1],
),
)
What does this code do?
- The min() function checks every node that belongs to graph.
- The distance_metres() function measures each node from START_LAT and START_LON.
- The winning OpenStreetMap node identifier becomes START_NODE.
- Save running_loop.py.
- Run the script with the same Python command you used in the previous step.
You should see the existing way count and node count complete without a traceback. This confirms that START_NODE points to a node in your graph.
Seeing an error in the nearest-node calculation?
- Confirm that node_coordinates maps each node identifier to its latitude and longitude.
- Check that graph contains the node identifiers stored in node_coordinates.
- Check that distance_metres() still accepts two latitude and longitude pairs.
Help me diagnose my nearest-node calculation.
Explore closed paths
Real street networks rarely contain a loop measuring exactly 5000 metres. A bounded depth-first search can compare closed paths by their distance from the target. The smallest difference wins.
- Add the closed-path search directly below the START_NODE calculation by pasting this code:
# Explore simple paths without revisiting intermediate nodes
BEST_LOOP = None
BEST_LOOP_DISTANCE_METRES = None
paths_to_check = [(START_NODE, [START_NODE], 0.0)]
SEARCH_STATES_LEFT = 200000
while paths_to_check and SEARCH_STATES_LEFT > 0:
SEARCH_STATES_LEFT -= 1
current_node, path, distance_so_far = paths_to_check.pop()
for next_node, edge_length in graph[current_node]:
candidate_distance = distance_so_far + edge_length
# Allow the starting node only when it closes a useful loop
if next_node == START_NODE:
if len(path) > 2 and candidate_distance <= TARGET_DISTANCE_METRES * 1.15:
if BEST_LOOP is None or abs(candidate_distance - TARGET_DISTANCE_METRES) < abs(BEST_LOOP_DISTANCE_METRES - TARGET_DISTANCE_METRES):
BEST_LOOP = path + [START_NODE]
BEST_LOOP_DISTANCE_METRES = candidate_distance
continue
# Keep each candidate simple and within the useful distance range
if next_node in path or candidate_distance > TARGET_DISTANCE_METRES * 1.15:
continue
paths_to_check.append((next_node, path + [next_node], candidate_distance))
if BEST_LOOP is None:
raise RuntimeError("No closed loop found within the search limit.")
# Report the measured distance of the closest closed loop
print(f"BEST_LOOP_DISTANCE_METRES = {BEST_LOOP_DISTANCE_METRES:.0f}")
How does the loop search work?
- Each entry in paths_to_check stores the current node. It also stores the visited path. It also stores the accumulated distance.
- The search rejects a neighbour already stored in path. This keeps intermediate nodes from repeating.
- A return to START_NODE turns the current path into a closed-loop candidate.
- The distance comparison updates BEST_LOOP whenever a candidate lands closer to TARGET_DISTANCE_METRES.
- Save running_loop.py.
Before you rerun the script, what distance do you expect the best closed path around your address to print?
- Run the script with the same Python command you used earlier.
You should see BEST_LOOP_DISTANCE_METRES = followed by the selected route length in metres. The value depends on the streets and paths around your address.
That search result is the key milestone: your script has found a closed route and measured how closely it matches your 5K target.
No closed loop found?
- Confirm that every street segment was added to graph in both directions.
- Check that START_NODE has at least two neighbours in graph.
- Confirm that edge lengths use metres so they can be compared with TARGET_DISTANCE_METRES.
Help me debug why the graph search cannot find a closed loop.
Your script now has a measured 5K candidate. Next, you will export that route and confirm the same closed shape appears outside the script.
Export and Review Your Route
Your graph now holds BEST_LOOP. The terminal also reports BEST_LOOP_DISTANCE_METRES for the selected route.
A distance alone cannot show whether the route is useful on the ground. This final step exports the path as GeoJSON for browser review.
It also exports the same path as GPX for a compatible map viewer or running app.
In this step, get ready to:
- Export BEST_LOOP to loop.geojson plus loop.gpx.
- Highlight the selected route over the complete street network.
- Open the GPX route in another app to verify its shape.
Export the selected loop
Both exports must preserve the node order in BEST_LOOP. That shared order keeps the route closed across both formats.
Why preserve the coordinate order?
Each coordinate represents the next point along the selected graph path. Changing the order can create jumps between unrelated streets.
The existing node-coordinate mapping provides the location for every node in BEST_LOOP. Both exporters can reuse that ordered sequence.
The browser export can reuse the GeoJSON line structure already used by network.geojson.
- At the end of running_loop.py, add the loop.geojson export for BEST_LOOP.
- Save running_loop.py.
- Rerun running_loop.py from the terminal.
You should see loop.geojson in the project folder. Good progress. Your selected route now has its own browser-ready file.
The portable export uses the same route order. Each point in loop.gpx stores the matching latitude plus longitude from the selected path.
- Directly after the loop.geojson export in running_loop.py, add the loop.gpx export using ordered track points.
- Save running_loop.py.
- Rerun running_loop.py from the terminal.
You should now see both loop.geojson plus loop.gpx in the project folder. The terminal should still print BEST_LOOP_DISTANCE_METRES.
Route files missing?
- Confirm the export logic runs after BEST_LOOP has been selected.
- Check that both exporters use the existing node-coordinate mapping for every node in BEST_LOOP.
- Look in the same project folder that contains running_loop.py.
Help me diagnose missing route export files.
Highlight the loop on your map
The base layer in map.html shows every downloaded street plus path. A separate route layer makes the selected loop easy to judge against that network.
- Add a thicker contrasting route layer sourced from loop.geojson after the existing network.geojson layer in map.html.
- Save map.html.
- Return to the browser tab showing your map.
Before you reload the page, do you expect the selected route to form one closed shape over the street network?
- Reload the browser page to draw the new route layer.
You should see the complete network in the background. A thicker contrasting line should trace the selected loop back to its starting point.
What should you inspect?
Follow the highlighted line around the map. It should remain on the downloaded street network before returning to its starting area.
This visual check exposes route jumps that a distance number cannot reveal. The map turns the graph result into something you can judge as a runner.
Route not highlighted?
- Confirm loop.geojson sits beside map.html.
- Check that the route layer loads after the base network layer.
- Confirm the route stroke is wider than the base network stroke.
Help me troubleshoot the missing highlighted route.
Review the GPX in another app
The browser proves that your GeoJSON export can be drawn. An independent map viewer or running app checks whether loop.gpx carries the same route beyond your own page.
- Start the route import flow in a compatible map viewer or your phone's running app.
Before you select the file, do you expect the imported GPX shape to match the highlighted browser route?
- Select loop.gpx from your project folder.
You should see the same closed shape shown in your browser. The route should return to its starting area.
- Compare the GPX route shape with the highlighted line in your browser map.
The two shapes should match. That agreement proves both exports preserve the ordered route selected by BEST_LOOP.
GPX shape does not match?
- Confirm the GPX exporter uses the same ordered coordinate sequence as loop.geojson.
- Check that each GPX track point receives the matching latitude plus longitude.
- Rerun running_loop.py before importing the file again.
Help me compare my GeoJSON and GPX route exports.
Strong finish. Your selected 5K loop now survives both a browser map and an independent GPX import.
Secret mission
Compare a 10K Route with Your 5K Loop
How does doubling your target distance change the area your running loop covers? Temporarily generate a 10K route from the same street network, compare it with your 5K baseline, then restore your original route files.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally, so keeping your files has no ongoing cost. Choose whether to preserve the complete project, remove its stale cache, or delete every project file.
Resources you used:
- Python source: running_loop.py.
- HTML map: map.html.
- GeoJSON street network: network.geojson.
- GeoJSON running loop: loop.geojson.
- GPX running loop: loop.gpx.
- Cached Overpass API response: overpass_response.json.
Keep everything running
No action is needed. Choose this if you want to keep experimenting with the route search or change TARGET_DISTANCE_METRES later.
Your map files remain available without any cloud charges. The cached response can become outdated as the underlying map data changes.
Pause - I'll come back to this later
Keep the script and route artifacts while removing the cached map response. This gives your next run a fresh copy of the surrounding street network.
Deleting the cache is safe. Your script can download a replacement when you run it again.
- Return to the file sidebar in the code editor from earlier.
- Right-click overpass_response.json.
- Choose your editor's file deletion option from the context menu.
- Confirm the deletion if your editor asks.
- Check that overpass_response.json no longer appears in the file sidebar.
You've kept the files that matter. The next run can download current map data.
Delete - I don't want to use this again
Remove all project files to clear the project from your machine. This permanently deletes your script, map, route exports, street network, and cached response.
Your saved GPX route disappears from this folder after deletion. Keep a copy elsewhere first if you want to use it again.
- Return to the file sidebar in the code editor from earlier.
- Select running_loop.py, map.html, network.geojson, loop.geojson, loop.gpx, and overpass_response.json.
- Open the context menu for the selected files.
- Choose your editor's file deletion option.
- Confirm the deletion if your editor asks.
- Check that none of the six files remains in the project folder.
That's your workspace cleared. No project files remain in the folder.
Nice Work!
Nice Work!
Your 5K loop is ready for the road. You turned a street address into a closed route that you can inspect in a browser or load into a compatible map viewer.
You've learned how to:
- Turn your chosen street address into latitude and longitude with Nominatim.
- Pull walkable map data from the Overpass API. Build an OpenStreetMap graph with metre-based edge lengths.
- Search for a closed loop near 5 kilometres. Export the route as GeoJSON in loop.geojson. Export it as GPX in loop.gpx. Confirm both files show the same route shape.
- Secret Mission: Change the target distance to 10 kilometres. Compare how much farther the new loop reaches.
Ready to quiz yourself?