Build an Uber Surge Pricing Simulator
Simulate Uber's dynamic pricing engine with a live hex map in Python.
Introduction
30 Second Summary
You request a ride after a concert and the price is 3x what it was an hour ago. That spike is not random. It is calculated in real time using algorithms that track exactly how many people need rides and how many drivers are nearby.
In this project, you will build a surge pricing simulator inspired by Uber's real architecture. You will use their open-source H3 hexagonal grid system to compute and visualize dynamic price multipliers across San Francisco.
What You'll Build
A real-time surge pricing simulator that divides San Francisco into hexagonal cells, tracks simulated drivers and riders in each cell, and displays color-coded surge zones on an interactive map.
By the end of this project, you'll have:
- A surge pricing engine that computes real-time multipliers per hexagonal zone based on supply and demand ratios.
- A city event simulator that generates realistic rider demand spikes from concerts, rush hours, bar closings, and rainstorms.
- An interactive Streamlit dashboard with a live hex map showing color-coded surge zones and sidebar controls to trigger events.
- Secret Mission: Add surge smoothing with a rate limiter so the multiplier ramps up and down gradually instead of jumping instantly.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, loops). No prior experience with geospatial libraries or data visualization is needed.
Set Up Your Python Environment
Before you can simulate surge pricing, you need a working Python environment with the right libraries installed.
In this step, you will install Python, set up an isolated project folder, and pull in the packages that power your simulator.
In this step, get ready to:
- Install and verify Python 3.10+.
- Create a project directory and virtual environment.
- Install the required packages for geospatial indexing, simulation, and visualization.
Install and verify Python 3.10+
Streamlit (the framework you will use for your dashboard) requires Python 3.10 or higher. Let's confirm your system meets that requirement.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Python version by running:
python3 --version
✔️ I see version 3.10 or higher
Your Python version is ready. Move on to the next substep.
ⓧ I see an older version
Your system has Python installed, but the version is below 3.10. Streamlit will not run on older versions.
macOS often ships with an older Python by default. Even after installing a newer version, the python3 command may still point to the old one.
- Download Python 3.10+ from the official site: python.org/downloads.
- Run the installer and follow the prompts.
- After installation, close and reopen your terminal.
- Run python3 --version again. If it still shows the old version, try python3.10 --version or python3.12 --version to confirm the new binary is available.
ⓧ Command not found
Python is not installed on your system. Let's fix that.
- Go to the official Python downloads page: python.org/downloads.
- Download the latest stable Python installer for your operating system.
- Run the installer. On Windows, make sure you check the Add Python to PATH checkbox before clicking Install Now.
- Close and reopen your terminal after installation.
- Run python3 --version again to confirm. You should see 3.10 or higher.
Windows users
On Windows, the command may be python --version instead of python3 --version. Use whichever one returns a version number.
Create your project directory and virtual environment
A virtual environment gives your project its own isolated set of packages. This prevents conflicts with other Python projects on your machine.
- Open VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the built-in terminal by pressing Ctrl+` (backtick key, top-left of your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir surge-simulator
cd surge-simulator
- Create a virtual environment inside your project folder by running:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to create a virtual environment in a folder called venv. This folder contains a private copy of the Python interpreter and a place to install packages without affecting your system.
- Activate the virtual environment by running:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activate command for your operating system. On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first, then try activating again.
help me activate my Python virtual environment
Install the required packages
Your simulator relies on six packages. Uber's H3 library handles hexagonal geospatial indexing. Folium renders interactive maps. Streamlit and streamlit-folium power the dashboard. NumPy and Pandas handle numerical computation and data.
- Create a requirements.txt file inside your surge-simulator folder. In VS Code, click File in the top menu bar.
- Click New File.
- Paste the following content into the file:
h3==4.4.2
streamlit==1.57.0
numpy==2.2.3
pandas==2.2.3
folium==0.19.5
streamlit-folium==0.25.0
What does this file do?
requirements.txt lists every package your project needs along with exact version numbers. Pinning versions ensures your simulator works the same way every time, regardless of when someone installs it.
- Save the file as requirements.txt. Press Cmd+S (macOS) or Ctrl+S (Windows).
- When the save dialog appears, make sure you are saving inside the surge-simulator folder and name the file requirements.txt.
- Confirm the file exists by running:
ls
You should see requirements.txt and the venv folder in the output.
- Install all packages by running:
pip install -r requirements.txt
This will download and install all six packages plus their dependencies. It may take a minute or two.
When it finishes, you should see a line that says Successfully installed followed by a list of package names.
Seeing errors during installation?
Make sure your virtual environment is active. You should see (venv) at the start of your prompt. If not, re-run the activate command from the previous substep.
If you see a permissions error, do not use sudo. Instead, confirm you are inside the surge-simulator directory with the virtual environment activated.
help me fix pip install errors in my virtual environment
✔️ Awesome, I've got everything!
Double check you have saved requirements.txt and that your install completed without errors.
ⓧ I'd like to double check the full code
Here is what your requirements.txt should contain:
h3==4.4.2
streamlit==1.57.0
numpy==2.2.3
pandas==2.2.3
folium==0.19.5
streamlit-folium==0.25.0
Your environment is ready. Next up, you will build the hexagonal grid engine that maps San Francisco into the same cell structure Uber uses for real surge pricing.
Build the Hexagonal Grid Engine
Your environment is ready with all the packages installed. Now it's time to build the foundation of your simulator: the hexagonal grid that divides San Francisco into pricing zones.
Uber doesn't use square grids to track riders and drivers. Squares have inconsistent neighbor distances (diagonal neighbors are farther than side neighbors), which creates uneven pricing boundaries. Instead, Uber built H3, an open-source library that divides the Earth into hexagonal cells. Each hexagon has exactly 6 equidistant neighbors, making surge calculations consistent in every direction.
In this step, get ready to:
- Define a HexCell data structure to store cell information.
- Build a CityGrid class that generates hexagonal cells using H3.
- Add methods for coordinate lookup and neighbor retrieval.
Define the hex cell data structure
Before building the grid, you need a data structure to represent each hexagonal cell. Each cell tracks its geographic position, how many drivers and riders are inside it, and its current surge multiplier.
- Create a new file called city_grid.py in your project folder by running:
touch city_grid.py
- Confirm the file exists by running:
ls
You should see city_grid.py and requirements.txt in the output.
- Open city_grid.py in VS Code and add the following code:
import h3
import numpy as np
from dataclasses import dataclass, field
# Define the data structure for a single hexagonal cell
@dataclass
class HexCell:
cell_id: str
lat: float
lng: float
drivers: int = 0
riders: int = 0
surge_multiplier: float = 1.0
What does this code do?
The @dataclass decorator automatically generates an __init__ method and other boilerplate for your class. Each HexCell stores:
- cell_id: the unique H3 identifier for this hexagon.
- lat and lng: the center coordinates of the cell.
- drivers and riders: current counts of supply and demand in this zone.
- surge_multiplier: the pricing multiplier (1.0 means no surge).
- Save city_grid.py.
You won't see a visible change yet. The next chunk builds the grid constructor that uses this data structure.
Build the hexagonal grid constructor
H3 at resolution 8 creates cells roughly 0.5 square kilometers in size. That's about a city block or two of coverage per cell, which matches the granularity Uber uses for surge pricing.
- In city_grid.py, add the following CityGrid class below the HexCell dataclass:
class CityGrid:
def __init__(self, center_lat: float, center_lng: float, radius_k: int = 4, resolution: int = 8):
self.resolution = resolution
# Convert the center point to an H3 cell ID
self.center = h3.latlng_to_cell(center_lat, center_lng, resolution)
# Generate all cells within radius_k rings of the center
cell_ids = h3.grid_disk(self.center, radius_k)
self.cells: dict[str, HexCell] = {}
# Build a HexCell object for each cell in the grid
for cell_id in cell_ids:
lat, lng = h3.cell_to_latlng(cell_id)
self.cells[cell_id] = HexCell(cell_id=cell_id, lat=lat, lng=lng)
What does this code do?
The constructor takes a center latitude/longitude (San Francisco's coordinates) and builds a hex grid around it.
- h3.latlng_to_cell converts a lat/lng point into the H3 cell that contains it.
- h3.grid_disk returns all cells within radius_k rings of the center. With radius_k=4, this generates 61 hexagonal cells covering downtown San Francisco.
- Each cell ID gets converted back to lat/lng coordinates and stored as a HexCell object in the self.cells dictionary.
Why hexagons instead of squares?
In a square grid, diagonal neighbors are about 41% farther away than side neighbors. This means surge pricing would "leak" unevenly depending on direction.
Hexagons have 6 equidistant neighbors, so surge blending is uniform in every direction. This is the real reason Uber built H3 instead of using a standard square grid.
- Save city_grid.py.
- Test your grid by running this command in your terminal:
python -c "from city_grid import CityGrid; grid = CityGrid(37.7749, -122.4194); print(f'Grid has {len(grid.cells)} cells')"
You should see Grid has 61 cells printed in your terminal.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is activated. You should see the environment name in parentheses at the start of your terminal prompt.
- Check that you saved city_grid.py in the same directory where your terminal is running. Run ls to confirm the file is there.
- If you see No module named 'h3', re-run pip install h3 with your virtual environment active.
Help me debug a ModuleNotFoundError when importing my city_grid module
Add coordinate lookup and neighbor methods
The grid needs methods to look up which cell a coordinate falls in, find neighboring cells, and get cell boundaries for map rendering.
- In city_grid.py, add these methods inside the CityGrid class (below the __init__ method, indented one level):
def get_cell(self, lat: float, lng: float) -> HexCell:
# Convert coordinates to the H3 cell they fall within
cell_id = h3.latlng_to_cell(lat, lng, self.resolution)
if cell_id in self.cells:
return self.cells[cell_id]
return None
def get_neighbors(self, cell_id: str) -> list[str]:
# Get the ring of cells exactly 1 step away
ring = h3.grid_ring(cell_id, 1)
return [c for c in ring if c in self.cells]
def get_cell_boundary(self, cell_id: str) -> list[tuple[float, float]]:
# Get the polygon boundary for rendering on a map
boundary = h3.cell_to_boundary(cell_id)
return [(lat, lng) for lat, lng in boundary]
def reset_demand(self):
# Clear all rider counts (called at the start of each simulation tick)
for cell in self.cells.values():
cell.riders = 0
What do these methods do?
- get_cell: takes a latitude and longitude, finds which hex cell contains that point. This is how you'll place riders and drivers on the grid.
- get_neighbors: uses h3.grid_ring to find the 6 adjacent cells. The surge algorithm uses this for spatial smoothing between zones.
- get_cell_boundary: returns the polygon coordinates of a cell's edges. You'll use this later to draw hexagons on the map.
- reset_demand: clears rider counts so each simulation tick starts fresh before new demand is added.
- Save city_grid.py.
- Verify the methods work by running this test:
python -c "from city_grid import CityGrid; grid = CityGrid(37.7749, -122.4194); cell = grid.get_cell(37.7749, -122.4194); print(f'Center cell: {cell.cell_id}'); print(f'Neighbors: {len(grid.get_neighbors(cell.cell_id))}')"
You should see a cell ID string (like 88283082e7fffff) and Neighbors: 6 confirming the center cell has 6 equidistant neighbors.
Seeing fewer than 6 neighbors?
- Make sure the utility methods are indented inside the CityGrid class, not at the top level of the file. Python uses indentation to determine class membership.
- If you get AttributeError: 'CityGrid' object has no attribute 'get_cell', double check that each method starts with exactly 4 spaces of indentation (one level inside the class).
Help me fix my CityGrid methods that aren't being recognized
✔️ Awesome, I've got everything!
Great. Double check you've saved city_grid.py before moving on.
ⓧ I'd like to double check the full code
import h3
import numpy as np
from dataclasses import dataclass, field
@dataclass
class HexCell:
cell_id: str
lat: float
lng: float
drivers: int = 0
riders: int = 0
surge_multiplier: float = 1.0
class CityGrid:
def __init__(self, center_lat: float, center_lng: float, radius_k: int = 4, resolution: int = 8):
self.resolution = resolution
self.center = h3.latlng_to_cell(center_lat, center_lng, resolution)
cell_ids = h3.grid_disk(self.center, radius_k)
self.cells: dict[str, HexCell] = {}
for cell_id in cell_ids:
lat, lng = h3.cell_to_latlng(cell_id)
self.cells[cell_id] = HexCell(cell_id=cell_id, lat=lat, lng=lng)
def get_cell(self, lat: float, lng: float) -> HexCell:
cell_id = h3.latlng_to_cell(lat, lng, self.resolution)
if cell_id in self.cells:
return self.cells[cell_id]
return None
def get_neighbors(self, cell_id: str) -> list[str]:
ring = h3.grid_ring(cell_id, 1)
return [c for c in ring if c in self.cells]
def get_cell_boundary(self, cell_id: str) -> list[tuple[float, float]]:
boundary = h3.cell_to_boundary(cell_id)
return [(lat, lng) for lat, lng in boundary]
def reset_demand(self):
for cell in self.cells.values():
cell.riders = 0
Your hexagonal grid engine is ready. Next up, you'll build the surge pricing algorithm that calculates multipliers based on how many riders and drivers are in each cell.
Implement the Surge Pricing Algorithm
Your hex grid divides San Francisco into cells that can track drivers and riders. Now you need the brain of the system.
In this step, you'll write the surge pricing algorithm that looks at each cell's demand and supply, then calculates a price multiplier. This is the same fundamental approach Uber uses to decide when prices go up.
In this step, get ready to:
- Build the core surge pricing function with threshold logic and a 3.0x cap.
- Add neighbor blending so adjacent cells influence each other's multipliers.
- Create a color mapping function for visualizing surge levels on the map.
Build the core surge pricing function
The surge multiplier for a cell depends on a single ratio: how many riders want a car divided by how many drivers are available. When demand exceeds supply past a threshold, prices ramp up to incentivize more drivers into the area.
- Create a new file called surge_engine.py in your project folder (the same folder as city_grid.py).
- Add the imports, constants, and core pricing function by pasting this code:
import numpy as np
# Surge pricing thresholds and limits
SURGE_THRESHOLD_LOW = 1.0
SURGE_THRESHOLD_HIGH = 1.2
SURGE_CAP = 3.0
NEIGHBOR_BLEND_WEIGHT = 0.15
def compute_surge(demand: int, supply: int) -> float:
# Handle the edge case where no drivers are available
if supply == 0:
if demand > 0:
return SURGE_CAP
return 1.0
ratio = demand / supply
# No surge if demand is at or below supply
if ratio <= SURGE_THRESHOLD_LOW:
return 1.0
elif ratio <= SURGE_THRESHOLD_HIGH:
return 1.0
else:
# Linear ramp from threshold to cap
multiplier = 1.0 + (ratio - SURGE_THRESHOLD_HIGH) * 1.5
return min(multiplier, SURGE_CAP)
What does this code do?
The four constants at the top define your pricing rules. SURGE_THRESHOLD_HIGH (1.2) means surge only kicks in when demand exceeds supply by 20%. SURGE_CAP (3.0) is the hard ceiling so prices never go above 3x.
The compute_surge function handles three scenarios. If there are zero drivers and riders are waiting, it immediately hits the cap. If the demand/supply ratio is below 1.2, no surge applies. Above 1.2, the multiplier ramps linearly until it hits 3.0x.
- Save surge_engine.py.
- Test the function by running this command in your terminal:
python -c "from surge_engine import compute_surge; print(compute_surge(5, 5)); print(compute_surge(8, 5)); print(compute_surge(10, 2))"
You should see three numbers printed: 1.0 (no surge, balanced demand), 1.6 (moderate surge), and 3.0 (hitting the cap).
Seeing a ModuleNotFoundError?
Make sure you're running the command from your project folder (the one containing surge_engine.py). Also confirm your virtual environment is active. You should see its name in parentheses at the start of your terminal prompt.
help me debug a ModuleNotFoundError when importing surge_engine
Why does surge activate at 1.2x and not 1.0x?
A small buffer between 1.0 and 1.2 prevents price flicker. If surge activated the instant one extra rider appeared, prices would bounce constantly. The threshold ensures surge only triggers when demand genuinely outpaces supply.
Add neighbor blending across cells
In a real city, a surge in one block affects the streets around it. Without blending, you'd see sharp price boundaries where one cell is 3.0x and its neighbor is 1.0x. Spatial smoothing fixes this by letting adjacent cells influence each other.
- In surge_engine.py, add the following function below compute_surge:
def compute_all_surges(city_grid) -> dict[str, float]:
# First pass: compute raw surge for each cell independently
raw_surges = {}
for cell_id, cell in city_grid.cells.items():
raw_surges[cell_id] = compute_surge(cell.riders, cell.drivers)
# Second pass: blend each cell's surge with its neighbors
blended_surges = {}
for cell_id in city_grid.cells:
neighbors = city_grid.get_neighbors(cell_id)
if neighbors:
neighbor_avg = np.mean([raw_surges[n] for n in neighbors])
blended = (1 - NEIGHBOR_BLEND_WEIGHT) * raw_surges[cell_id] + NEIGHBOR_BLEND_WEIGHT * neighbor_avg
else:
blended = raw_surges[cell_id]
blended_surges[cell_id] = round(min(blended, SURGE_CAP), 2)
# Update each cell's stored multiplier
for cell_id, surge in blended_surges.items():
city_grid.cells[cell_id].surge_multiplier = surge
return blended_surges
What does this code do?
This function runs two passes over all cells. The first pass calculates each cell's raw surge using compute_surge. The second pass blends each cell's value with the average of its neighbors.
The NEIGHBOR_BLEND_WEIGHT (0.15) controls how much influence neighbors have. At 15%, the cell's own ratio dominates, but neighboring surges gently bleed across boundaries. This is how Uber avoids jarring price cliffs between adjacent streets.
- Save surge_engine.py.
You won't see a change yet. The next function wires surge values to map colors, and the dashboard (built in a later step) will bring everything together visually.
Seeing a NameError for np or NEIGHBOR_BLEND_WEIGHT?
Make sure your import numpy as np statement is at the top of surge_engine.py. Also confirm the four constants (SURGE_THRESHOLD_LOW, SURGE_THRESHOLD_HIGH, SURGE_CAP, NEIGHBOR_BLEND_WEIGHT) are defined above both functions.
help me fix a NameError in my surge_engine.py
Map surge multipliers to colors
The dashboard will color-code each hex cell by its surge level. You need a function that converts a numeric multiplier into a hex color string.
- In surge_engine.py, add this function below compute_all_surges:
def get_surge_color(multiplier: float) -> str:
# Map surge levels to a green-yellow-orange-red-purple gradient
if multiplier <= 1.0:
return "#2ecc71"
elif multiplier <= 1.3:
return "#f1c40f"
elif multiplier <= 1.8:
return "#e67e22"
elif multiplier <= 2.5:
return "#e74c3c"
else:
return "#8e44ad"
What does this code do?
Each threshold maps to a color the learner will see on the map. Green means no surge. Yellow means mild (up to 1.3x). Orange means moderate (up to 1.8x). Red means high (up to 2.5x). Purple means extreme (above 2.5x).
- Save surge_engine.py.
- Verify the complete engine by running this test in your terminal:
python -c "from surge_engine import compute_surge, get_surge_color; print(compute_surge(8, 5)); print(get_surge_color(1.6))"
You should see 1.6 (the surge multiplier for 8 riders and 5 drivers) and #e67e22 (orange, because 1.6x falls between 1.3 and 1.8).
Seeing an ImportError for get_surge_color?
Make sure you saved surge_engine.py after adding the function. Python imports the file from disk, so unsaved changes won't be available.
help me fix an ImportError in my surge pricing project
✔️ Awesome, I've got everything!
Great. Double check you've saved surge_engine.py and both test commands produced the expected output.
ⓧ I'd like to double check the full code
Here's the complete surge_engine.py file. Compare yours against it:
import numpy as np
SURGE_THRESHOLD_LOW = 1.0
SURGE_THRESHOLD_HIGH = 1.2
SURGE_CAP = 3.0
NEIGHBOR_BLEND_WEIGHT = 0.15
def compute_surge(demand: int, supply: int) -> float:
if supply == 0:
if demand > 0:
return SURGE_CAP
return 1.0
ratio = demand / supply
if ratio <= SURGE_THRESHOLD_LOW:
return 1.0
elif ratio <= SURGE_THRESHOLD_HIGH:
return 1.0
else:
multiplier = 1.0 + (ratio - SURGE_THRESHOLD_HIGH) * 1.5
return min(multiplier, SURGE_CAP)
def compute_all_surges(city_grid) -> dict[str, float]:
raw_surges = {}
for cell_id, cell in city_grid.cells.items():
raw_surges[cell_id] = compute_surge(cell.riders, cell.drivers)
blended_surges = {}
for cell_id in city_grid.cells:
neighbors = city_grid.get_neighbors(cell_id)
if neighbors:
neighbor_avg = np.mean([raw_surges[n] for n in neighbors])
blended = (1 - NEIGHBOR_BLEND_WEIGHT) * raw_surges[cell_id] + NEIGHBOR_BLEND_WEIGHT * neighbor_avg
else:
blended = raw_surges[cell_id]
blended_surges[cell_id] = round(min(blended, SURGE_CAP), 2)
for cell_id, surge in blended_surges.items():
city_grid.cells[cell_id].surge_multiplier = surge
return blended_surges
def get_surge_color(multiplier: float) -> str:
if multiplier <= 1.0:
return "#2ecc71"
elif multiplier <= 1.3:
return "#f1c40f"
elif multiplier <= 1.8:
return "#e67e22"
elif multiplier <= 2.5:
return "#e74c3c"
else:
return "#8e44ad"
Your surge engine is complete. It knows how to price individual cells, smooth boundaries between neighbors, and map multipliers to visual colors. Next up, you'll build the event simulator that generates realistic demand spikes across the city.
Create the City Simulator
Your hex grid can compute surge multipliers, but it needs real data to work with. Right now, the cells have no drivers and no riders.
In this step, you will build a city simulator that populates the grid with drivers, generates realistic rider demand through city events, and makes drivers respond to surge by drifting toward high-multiplier cells.
In this step, get ready to:
- Build an event system that defines rider demand spikes like concerts, rush hour, and bar close.
- Simulate driver supply with baseline distribution and drift toward high-surge areas.
- Wire events into the hex grid so triggering one floods specific cells with rider demand.
Build the event system
A surge pricing engine is only useful if there are demand spikes to respond to. You will define four city events that each generate a burst of rider requests in a concentrated area of the hex grid.
- Create a new file called simulator.py in your project folder.
- Add the imports and event definitions by pasting the following code:
import random
import numpy as np
# Define city events that generate rider demand spikes
EVENTS = {
"concert_ends": {
"label": "Concert Ends (Mission District)",
"center_offset": (0, -2),
"rider_burst": 80,
"radius": 2,
},
"rush_hour": {
"label": "Rush Hour (Financial District)",
"center_offset": (2, 1),
"rider_burst": 50,
"radius": 3,
},
"bar_close": {
"label": "Bar Close (Marina)",
"center_offset": (-1, 2),
"rider_burst": 60,
"radius": 2,
},
"rainstorm": {
"label": "Rainstorm (City-wide)",
"center_offset": (0, 0),
"rider_burst": 30,
"radius": 4,
},
}
What does this code do?
Each event in the EVENTS dictionary models a real-world scenario that spikes rider demand in a specific area of San Francisco.
- center_offset positions the event relative to the grid center using H3's local IJ coordinate system.
- rider_burst controls how many riders flood the area when the event fires.
- radius determines how many hex rings of cells are affected.
- Save simulator.py.
You will not see output yet. The next functions will use this event data to populate the grid.
Simulate driver supply that responds to surge
The surge engine needs both supply (drivers) and demand (riders) to calculate multipliers. You will write two functions that set up the baseline simulation state, then a third that makes drivers drift toward high-surge cells over time.
- In simulator.py, add the following two functions below the EVENTS dictionary:
def initialize_drivers(city_grid, total_drivers: int = 120):
# Distribute drivers randomly across all cells
cell_ids = list(city_grid.cells.keys())
for cell in city_grid.cells.values():
cell.drivers = 0
for _ in range(total_drivers):
cell_id = random.choice(cell_ids)
city_grid.cells[cell_id].drivers += 1
def add_baseline_demand(city_grid, base_riders: int = 40):
# Reset demand and sprinkle background riders across the city
cell_ids = list(city_grid.cells.keys())
city_grid.reset_demand()
for _ in range(base_riders):
cell_id = random.choice(cell_ids)
city_grid.cells[cell_id].riders += 1
What does this code do?
These two functions set up the starting conditions for each simulation tick.
- initialize_drivers clears all existing drivers and randomly places 120 across the grid. This models a city with drivers scattered in no particular pattern.
- add_baseline_demand resets all rider counts to zero, then sprinkles 40 riders randomly. This represents the steady background of ride requests happening everywhere.
- Save simulator.py.
You will not see output yet. The next function builds on these to create dynamic driver behavior.
- Still in simulator.py, add the drift function below add_baseline_demand:
def drift_drivers_toward_surge(city_grid, drift_probability: float = 0.2):
# Move some drivers from low-surge cells toward higher-surge neighbors
import h3
moves = []
for cell_id, cell in city_grid.cells.items():
if cell.drivers > 0 and cell.surge_multiplier < 1.5:
neighbors = city_grid.get_neighbors(cell_id)
high_surge_neighbors = [
n for n in neighbors
if city_grid.cells[n].surge_multiplier > cell.surge_multiplier
]
if high_surge_neighbors and random.random() < drift_probability:
target = random.choice(high_surge_neighbors)
moves.append((cell_id, target))
for source, target in moves:
if city_grid.cells[source].drivers > 0:
city_grid.cells[source].drivers -= 1
city_grid.cells[target].drivers += 1
Why do drivers drift toward surge?
In the real world, drivers chase surge pricing because higher multipliers mean higher earnings. This function simulates that behavior.
A driver in a low-surge cell (below 1.5x) has a 20% chance each tick of moving toward a neighboring cell with a higher multiplier. Over multiple ticks, this gradually rebalances supply toward demand, which is exactly why real surge pricing eventually comes back down.
- Save simulator.py.
- Verify the module loads without errors by running this command in your terminal:
python3 -c "from simulator import initialize_drivers, add_baseline_demand, drift_drivers_toward_surge; print('Driver functions loaded OK')"
You should see Driver functions loaded OK printed in the terminal.
Seeing an ImportError?
- Make sure your virtual environment is activated. You should see the environment name in your terminal prompt.
- Check that simulator.py is saved in the same folder as city_grid.py and surge_engine.py.
- Look for typos in the function names or missing colons at the end of def lines.
Help me debug an ImportError in my simulator.py file.
Wire events into the hex grid
The EVENTS dictionary defines what happens, but you still need the function that actually fires an event into the grid. This function uses H3's local_ij_to_cell to locate the event's center, then floods surrounding cells with riders.
- In simulator.py, add the trigger_event function between add_baseline_demand and drift_drivers_toward_surge:
def trigger_event(city_grid, event_key: str):
# Fire a city event, flooding nearby cells with rider demand
import h3
event = EVENTS[event_key]
center_cell = city_grid.center
affected_cells = h3.grid_disk(center_cell, event["radius"])
# Use IJ coordinates to offset the event center from the grid center
offset_row, offset_col = event["center_offset"]
try:
local_center = h3.local_ij_to_cell(center_cell, offset_row, offset_col)
affected_cells = h3.grid_disk(local_center, event["radius"])
except Exception:
affected_cells = h3.grid_disk(center_cell, event["radius"])
valid_cells = [c for c in affected_cells if c in city_grid.cells]
# Distribute the rider burst randomly among affected cells
for _ in range(event["rider_burst"]):
if valid_cells:
cell_id = random.choice(valid_cells)
city_grid.cells[cell_id].riders += 1
What does this code do?
This function takes an event key (like "concert_ends") and simulates the demand spike on the grid.
- h3.local_ij_to_cell converts the event's center_offset into an actual hex cell. This positions each event in a different San Francisco neighborhood.
- h3.grid_disk grabs all cells within the event's radius, giving you a cluster of affected hexagons.
- The try/except handles edge cases where the IJ offset lands outside the grid. It falls back to centering the event on the grid origin.
- Save simulator.py.
- Verify the complete module works by running this test in your terminal:
python3 -c "
from city_grid import CityGrid
from simulator import initialize_drivers, add_baseline_demand, trigger_event, drift_drivers_toward_surge
grid = CityGrid(37.7749, -122.4194, radius_k=4, resolution=8)
initialize_drivers(grid, total_drivers=120)
add_baseline_demand(grid, base_riders=40)
trigger_event(grid, 'concert_ends')
print(f'Total riders after concert: {sum(c.riders for c in grid.cells.values())}')
print('Simulator working!')
"
You should see a total rider count around 120 (40 baseline + 80 from the concert event) and the message Simulator working! printed.
Seeing a NameError or unexpected count?
- If you see NameError: name 'EVENTS' is not defined, make sure trigger_event is in the same file as the EVENTS dictionary.
- If the rider count is exactly 40, the event did not fire. Check that trigger_event is placed after add_baseline_demand and before drift_drivers_toward_surge in the file.
- Check your indentation inside the for loop at the end of trigger_event. Python is sensitive to consistent spacing.
Help me debug my trigger_event function in the surge simulator.
✔️ Awesome, I've got everything!
Great. Double check you have saved simulator.py with all four functions in place.
ⓧ I'd like to double check the full code
Here is the complete simulator.py file. Compare it against yours:
import random
import numpy as np
EVENTS = {
"concert_ends": {
"label": "Concert Ends (Mission District)",
"center_offset": (0, -2),
"rider_burst": 80,
"radius": 2,
},
"rush_hour": {
"label": "Rush Hour (Financial District)",
"center_offset": (2, 1),
"rider_burst": 50,
"radius": 3,
},
"bar_close": {
"label": "Bar Close (Marina)",
"center_offset": (-1, 2),
"rider_burst": 60,
"radius": 2,
},
"rainstorm": {
"label": "Rainstorm (City-wide)",
"center_offset": (0, 0),
"rider_burst": 30,
"radius": 4,
},
}
def initialize_drivers(city_grid, total_drivers: int = 120):
cell_ids = list(city_grid.cells.keys())
for cell in city_grid.cells.values():
cell.drivers = 0
for _ in range(total_drivers):
cell_id = random.choice(cell_ids)
city_grid.cells[cell_id].drivers += 1
def add_baseline_demand(city_grid, base_riders: int = 40):
cell_ids = list(city_grid.cells.keys())
city_grid.reset_demand()
for _ in range(base_riders):
cell_id = random.choice(cell_ids)
city_grid.cells[cell_id].riders += 1
def trigger_event(city_grid, event_key: str):
import h3
event = EVENTS[event_key]
center_cell = city_grid.center
affected_cells = h3.grid_disk(center_cell, event["radius"])
offset_row, offset_col = event["center_offset"]
try:
local_center = h3.local_ij_to_cell(center_cell, offset_row, offset_col)
affected_cells = h3.grid_disk(local_center, event["radius"])
except Exception:
affected_cells = h3.grid_disk(center_cell, event["radius"])
valid_cells = [c for c in affected_cells if c in city_grid.cells]
for _ in range(event["rider_burst"]):
if valid_cells:
cell_id = random.choice(valid_cells)
city_grid.cells[cell_id].riders += 1
def drift_drivers_toward_surge(city_grid, drift_probability: float = 0.2):
import h3
moves = []
for cell_id, cell in city_grid.cells.items():
if cell.drivers > 0 and cell.surge_multiplier < 1.5:
neighbors = city_grid.get_neighbors(cell_id)
high_surge_neighbors = [
n for n in neighbors
if city_grid.cells[n].surge_multiplier > cell.surge_multiplier
]
if high_surge_neighbors and random.random() < drift_probability:
target = random.choice(high_surge_neighbors)
moves.append((cell_id, target))
for source, target in moves:
if city_grid.cells[source].drivers > 0:
city_grid.cells[source].drivers -= 1
city_grid.cells[target].drivers += 1
Your city simulator is ready to generate realistic demand patterns. Next up, you will build the Streamlit dashboard that visualizes everything on a live hex map.
Build the Streamlit Dashboard
You have a grid engine, a surge calculator, and a city simulator. All three modules work behind the scenes, but there is no way to see what is happening.
In this step, you will build an interactive Streamlit dashboard that renders the hex grid on a live map, color-codes cells by surge multiplier, and lets you trigger city events with a single click.
In this step, get ready to:
- Set up the Streamlit app layout and initialize simulation state.
- Build a sidebar with event trigger buttons and a simulation stepper.
- Render the hex grid on a Folium map with color-coded surge zones and live metrics.
Set up the app layout and session state
Streamlit re-runs your entire script on every interaction (button click, slider move). To keep the simulation state alive between reruns, you store the grid and tick counter in st.session_state.
- Create a new file called app.py in your project folder.
- Add the imports, page config, and session state initialization by pasting this code:
import streamlit as st
import folium
from streamlit_folium import st_folium
from city_grid import CityGrid
from surge_engine import compute_all_surges, get_surge_color
from simulator import (
initialize_drivers,
add_baseline_demand,
trigger_event,
drift_drivers_toward_surge,
EVENTS,
)
# Configure the page layout and title
st.set_page_config(page_title="Uber Surge Simulator", layout="wide")
st.title("Uber Surge Pricing Simulator")
st.caption("Powered by Uber's H3 hexagonal geospatial indexing system")
# San Francisco coordinates as the city center
SF_LAT = 37.7749
SF_LNG = -122.4194
# Initialize state only on first run (persists across reruns)
if "grid" not in st.session_state:
st.session_state.grid = CityGrid(SF_LAT, SF_LNG, radius_k=4, resolution=8)
initialize_drivers(st.session_state.grid, total_drivers=120)
add_baseline_demand(st.session_state.grid, base_riders=40)
compute_all_surges(st.session_state.grid)
st.session_state.tick = 0
grid = st.session_state.grid
What does this code do?
The imports pull in your three custom modules (city_grid, surge_engine, simulator) along with Streamlit and Folium for the web UI and map.
The if "grid" not in st.session_state block runs only once. It creates the hex grid centered on San Francisco, distributes 120 drivers, adds 40 baseline riders, and computes the initial surge. On every subsequent rerun, the existing state is reused.
- Save app.py.
- Run the app to confirm it launches by running this command:
streamlit run app.py
A browser tab opens showing the title "Uber Surge Pricing Simulator" and the caption below it. The page is otherwise empty for now.
Seeing a ModuleNotFoundError?
Make sure your virtual environment is activated before running the command. If you see an error about streamlit_folium or h3, re-run pip install -r requirements.txt to install all dependencies.
help me fix a ModuleNotFoundError when running streamlit run app.py
Build the sidebar controls
The sidebar gives you one-click access to city events and a button that advances the simulation by one tick. Each event button floods specific hex cells with rider demand, then immediately recalculates surge across the grid.
- In app.py, add the following code below the grid = st.session_state.grid line:
with st.sidebar:
st.header("City Events")
st.write("Trigger an event to spike rider demand:")
for key, event in EVENTS.items():
if st.button(event["label"], key=key):
trigger_event(grid, key)
compute_all_surges(grid)
st.divider()
if st.button("Step Simulation", type="primary"):
add_baseline_demand(grid, base_riders=40)
drift_drivers_toward_surge(grid, drift_probability=0.2)
compute_all_surges(grid)
st.session_state.tick += 1
st.metric("Simulation Tick", st.session_state.tick)
What does this code do?
- The for key, event in EVENTS.items() loop creates one button per event (Concert Ends, Rush Hour, Bar Close, Rainstorm). Clicking any button calls trigger_event then recalculates surge.
- The Step Simulation button simulates one time tick. It resets and re-adds baseline demand, drifts drivers toward high-surge cells, recomputes surge, and increments the tick counter.
- The st.metric call displays the current tick count in the sidebar so you can track how far the simulation has progressed.
- Save app.py.
- Check your browser. The sidebar now shows four event buttons and a blue Step Simulation button.
Sidebar not appearing?
Make sure the sidebar code starts with with st.sidebar: and everything inside it is indented one level. If the browser tab closed, re-run streamlit run app.py.
help me debug my Streamlit sidebar not showing up
Display metrics and render the hex map
The final piece is a metrics summary row and the hex map itself. The map uses Folium to draw each hex cell as a colored polygon. Green means no surge, yellow through red means increasing surge, and purple means extreme demand.
- In app.py, add the metrics row below the sidebar block:
# Display key metrics in a 3-column row
col1, col2, col3 = st.columns(3)
surges = [cell.surge_multiplier for cell in grid.cells.values()]
with col1:
st.metric("Max Surge", f"{max(surges):.1f}x")
with col2:
st.metric("Avg Surge", f"{sum(surges)/len(surges):.2f}x")
with col3:
surging_count = sum(1 for s in surges if s > 1.0)
st.metric("Cells Surging", f"{surging_count}/{len(surges)}")
What do these metrics show?
- Max Surge shows the highest multiplier across all cells.
- Avg Surge shows the mean multiplier. When no events are active, this hovers near 1.0x.
- Cells Surging counts how many cells have a multiplier above 1.0x out of the total grid.
- Now add the Folium map rendering and color legend directly below the metrics code:
# Create the Folium map centered on San Francisco
m = folium.Map(location=[SF_LAT, SF_LNG], zoom_start=13, tiles="cartodbpositron")
# Draw each hex cell as a colored polygon
for cell_id, cell in grid.cells.items():
boundary = grid.get_cell_boundary(cell_id)
color = get_surge_color(cell.surge_multiplier)
folium.Polygon(
locations=boundary,
color=color,
fill=True,
fill_color=color,
fill_opacity=0.5,
weight=1,
tooltip=f"Surge: {cell.surge_multiplier:.1f}x | Drivers: {cell.drivers} | Riders: {cell.riders}",
).add_to(m)
# Render the map in Streamlit
st_folium(m, width=900, height=500, returned_objects=[])
st.markdown("---")
st.markdown(
"**Color Legend:** "
':green[1.0x] \u00b7 :orange[1.3x-1.8x] \u00b7 :red[2.0x+] \u00b7 :violet[2.5x+]'
)
What does this code do?
- folium.Map creates a map centered on San Francisco with a clean light-gray tile style (cartodbpositron).
- The loop draws one polygon per hex cell. Each polygon's color comes from get_surge_color which maps multiplier ranges to green, yellow, orange, red, or purple.
- The tooltip shows the cell's surge multiplier, driver count, and rider count when you hover over it.
- st_folium renders the Folium map inside the Streamlit app at 900x500 pixels.
- Save app.py and check your browser.
- You should see a map of San Francisco covered in green hexagonal cells. The metrics row above the map shows a Max Surge near 1.0x.
- Click one of the event buttons in the sidebar (try Concert Ends (Mission District)). Some cells turn yellow, orange, or red as rider demand spikes.
- Click Step Simulation a few times. Watch drivers drift toward the surge zone and the colors gradually fade back toward green.
Map not rendering?
If you see a blank space where the map should be, check that streamlit-folium is installed. Run pip install streamlit-folium and restart the app.
If you see an error about folium.Polygon, make sure the locations=boundary line passes a list of lat/lng tuples. Double-check your indentation inside the for loop.
help me debug why my Folium map is not showing in Streamlit
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and that the map renders with colored hexagons when you trigger an event.
ⓧ I'd like to double check the full code
Here is the complete app.py file. Compare it to yours and fix any differences.
import streamlit as st
import folium
from streamlit_folium import st_folium
from city_grid import CityGrid
from surge_engine import compute_all_surges, get_surge_color
from simulator import (
initialize_drivers,
add_baseline_demand,
trigger_event,
drift_drivers_toward_surge,
EVENTS,
)
st.set_page_config(page_title="Uber Surge Simulator", layout="wide")
st.title("Uber Surge Pricing Simulator")
st.caption("Powered by Uber's H3 hexagonal geospatial indexing system")
SF_LAT = 37.7749
SF_LNG = -122.4194
if "grid" not in st.session_state:
st.session_state.grid = CityGrid(SF_LAT, SF_LNG, radius_k=4, resolution=8)
initialize_drivers(st.session_state.grid, total_drivers=120)
add_baseline_demand(st.session_state.grid, base_riders=40)
compute_all_surges(st.session_state.grid)
st.session_state.tick = 0
grid = st.session_state.grid
with st.sidebar:
st.header("City Events")
st.write("Trigger an event to spike rider demand:")
for key, event in EVENTS.items():
if st.button(event["label"], key=key):
trigger_event(grid, key)
compute_all_surges(grid)
st.divider()
if st.button("Step Simulation", type="primary"):
add_baseline_demand(grid, base_riders=40)
drift_drivers_toward_surge(grid, drift_probability=0.2)
compute_all_surges(grid)
st.session_state.tick += 1
st.metric("Simulation Tick", st.session_state.tick)
col1, col2, col3 = st.columns(3)
surges = [cell.surge_multiplier for cell in grid.cells.values()]
with col1:
st.metric("Max Surge", f"{max(surges):.1f}x")
with col2:
st.metric("Avg Surge", f"{sum(surges)/len(surges):.2f}x")
with col3:
surging_count = sum(1 for s in surges if s > 1.0)
st.metric("Cells Surging", f"{surging_count}/{len(surges)}")
m = folium.Map(location=[SF_LAT, SF_LNG], zoom_start=13, tiles="cartodbpositron")
for cell_id, cell in grid.cells.items():
boundary = grid.get_cell_boundary(cell_id)
color = get_surge_color(cell.surge_multiplier)
folium.Polygon(
locations=boundary,
color=color,
fill=True,
fill_color=color,
fill_opacity=0.5,
weight=1,
tooltip=f"Surge: {cell.surge_multiplier:.1f}x | Drivers: {cell.drivers} | Riders: {cell.riders}",
).add_to(m)
st_folium(m, width=900, height=500, returned_objects=[])
st.markdown("---")
st.markdown(
"**Color Legend:** "
':green[1.0x] \u00b7 :orange[1.3x-1.8x] \u00b7 :red[2.0x+] \u00b7 :violet[2.5x+]'
)
Your surge pricing simulator is fully operational. You can trigger city events, watch demand propagate across hexagonal zones, and see drivers respond to high-multiplier cells in real time. Next up, the Secret Mission challenges you to add surge smoothing so the multiplier ramps gradually instead of jumping instantly.
Secret mission
Add Surge Smoothing
Real Uber surge does not jump instantly from 1.0x to 3.0x. It ramps in controlled steps to prevent oscillation. Can you add rate-limited smoothing to your simulator so the multiplier rises and falls gradually, just like the real system?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing cloud costs or paid services to worry about. Decide whether to keep your files for future experimentation, or delete them to free up disk space.
Resources you used:
- Project folder on your Desktop (contains city_grid.py, surge_engine.py, simulator.py, app.py, requirements.txt).
- Python virtual environment (inside your project folder).
- Installed pip packages (h3, streamlit, numpy, pandas, folium, streamlit-folium) inside the virtual environment.
- Running Streamlit server (if still active in your terminal).
Keep everything running
No action needed. Choose this if you want to keep experimenting with events, tuning surge thresholds, or extending the simulator.
- Your project folder stays on your Desktop with all files intact.
- You can restart the Streamlit app any time by activating your virtual environment and running streamlit run app.py.
Pause - I'll come back to this later
Stop the running Streamlit server to free up your terminal. Your files stay on disk so you can pick up where you left off.
- In the terminal where Streamlit is running, press Ctrl+C to stop the server.
- If your virtual environment is active, type deactivate and press Enter to exit it.
- Your project folder and all files remain on your Desktop for next time.
Delete - I don't want to use this again
Remove all project files and the virtual environment. This frees up disk space and cleans up everything you created.
- In the terminal where Streamlit is running, press Ctrl+C to stop the server.
- If your virtual environment is active, type deactivate and press Enter.
- Delete the entire project folder by running one of these commands from your Desktop:
macOS
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rm -rf surge-pricing-simulator
Windows
- Navigate to your Desktop and delete the project folder by running:
cd ~\Desktop
rmdir /s /q surge-pricing-simulator
This removes all Python files, the virtual environment, and all installed packages in one step. Nothing else on your system is affected.
Nice Work!
Nice Work!
You just built a surge pricing simulator powered by the same geospatial technology Uber uses in production.
You've learned how to:
- Build a hexagonal grid engine using Uber's open-source H3 library at resolution 8 to divide a city into geospatial cells.
- Implement the core surge pricing algorithm with demand/supply ratios, activation thresholds, multiplier caps, and spatial blending across neighbor cells.
- Visualize real-time surge data on an interactive Streamlit dashboard with a live color-coded Folium hex map and event-driven simulation controls.
- Secret Mission: Added rate-limited surge smoothing to prevent price oscillation, replicating how Uber's real system ramps multipliers in controlled steps.
Ready to quiz yourself?