Computing Edge Grade and Energy Cost
Routing an electric vehicle on driving time and reporting range separately is the arrangement that strands people. The fastest route over a pass and the cheapest route around it are different routes, and a system that optimises one while displaying the other will confidently show a battery percentage that turns out to be wrong at the top of the climb. Making energy a first-class edge cost fixes that — the router minimises what actually runs out, and the arrival estimate is the thing that was optimised rather than a number computed afterwards. This page derives watt-hours per edge from gradient, speed and vehicle mass, and handles the asymmetry that makes descending cheap without ever making it free.
Prerequisites & Versions
The model is arithmetic over properties the elevation enrichment has already written.
| Requirement | Minimum version | Install |
|---|---|---|
| Python | 3.11 | — |
| neo4j (async driver) | 5.20 | pip install "neo4j>=5.20" |
| Neo4j Server | 5.15 | native point, directed relationships |
Implementation
The model computes the force balance on a segment, converts it to energy over the segment’s length, and applies drivetrain and regeneration efficiencies on the appropriate side of zero.
import asyncio
import math
from dataclasses import dataclass
from neo4j import AsyncGraphDatabase
G = 9.80665 # m/s²
AIR_DENSITY = 1.225 # kg/m³ at sea level, 15 °C
@dataclass(frozen=True)
class Vehicle:
mass_kg: float
frontal_area_m2: float
drag_coefficient: float
rolling_resistance: float
drivetrain_efficiency: float = 0.90
regen_efficiency: float = 0.60 # what fraction of braking energy returns
accessory_w: float = 900.0 # HVAC, lights, electronics
@dataclass(frozen=True)
class EdgeEnergy:
segment_id: str
wh: float
regenerating: bool
def edge_energy_wh(v: Vehicle, length_m: float, grade_pct: float,
speed_mps: float) -> EdgeEnergy | float:
"""Watt-hours to traverse one directed segment.
Three forces oppose motion and one can assist it. Rolling resistance and
aerodynamic drag always oppose; the gravity term opposes on a climb and
assists on a descent, which is where the sign of grade_pct enters.
"""
theta = math.atan(grade_pct / 100.0)
rolling_n = v.rolling_resistance * v.mass_kg * G * math.cos(theta)
drag_n = 0.5 * AIR_DENSITY * v.drag_coefficient * v.frontal_area_m2 * speed_mps ** 2
gravity_n = v.mass_kg * G * math.sin(theta) # negative when descending
net_n = rolling_n + drag_n + gravity_n
mechanical_j = net_n * length_m
if mechanical_j >= 0:
# Driving: the battery supplies the work, and the drivetrain loses some.
battery_j = mechanical_j / v.drivetrain_efficiency
else:
# Descending steeply enough that gravity exceeds the resistances. Only a
# fraction of the surplus comes back, and it comes back through the same
# drivetrain — so this is never the mirror image of the climb.
battery_j = mechanical_j * v.regen_efficiency * v.drivetrain_efficiency
# Accessories draw whether the vehicle is climbing, descending or stopped,
# so they scale with TIME on the edge, not with distance.
seconds = length_m / max(speed_mps, 0.1)
battery_j += v.accessory_w * seconds
return battery_j / 3600.0
WRITE_ENERGY = """
UNWIND $batch AS row
MATCH ()-[s:SEGMENT {id: row.id}]->()
SET s.energy_wh = row.wh
RETURN count(s) AS updated
"""
READ_SEGMENTS = """
MATCH ()-[s:SEGMENT]->()
WHERE s.grade_pct IS NOT NULL AND s.length_m > 0
RETURN s.id AS id, s.length_m AS length_m, s.grade_pct AS grade_pct,
coalesce(s.free_flow_mps, 13.9) AS speed_mps
"""
async def annotate(uri: str, auth: tuple[str, str], vehicle: Vehicle,
batch: int = 10_000) -> int:
driver = AsyncGraphDatabase.driver(uri, auth=auth)
written = 0
try:
async with driver.session() as session:
result = await session.run(READ_SEGMENTS)
buffer: list[dict] = []
async for record in result:
wh = edge_energy_wh(
vehicle,
float(record["length_m"]),
float(record["grade_pct"]),
float(record["speed_mps"]),
)
buffer.append({"id": record["id"], "wh": round(wh, 4)})
if len(buffer) >= batch:
written += await _flush(session, buffer)
buffer.clear()
if buffer:
written += await _flush(session, buffer)
finally:
await driver.close()
return written
async def _flush(session, buffer: list[dict]) -> int:
result = await session.run(WRITE_ENERGY, batch=buffer)
return int((await result.single())["updated"])
How It Works
Three parts of the model matter more than the constants.
Gravity is the only term whose sign flips. Rolling resistance and drag oppose motion in both directions; the gravity term is m·g·sin(θ), and sin is negative for a descent. That single sign change is what makes the edge cost directional, and it is why the graph needs two relationships per two-way road rather than one with a magnitude.
Regeneration is bounded twice, which is why descending never repays the climb. The surplus energy on a descent is multiplied by the regeneration efficiency and by the drivetrain efficiency, because it travels back through the same machinery it went out through. At typical figures, roughly half of what a climb cost comes back on the matching descent. A model that returns the full surplus produces routes that treat hills as free and, worse, produces range estimates that improve when the road goes down.
Accessories scale with time, not distance. Climate control and electronics draw a roughly constant power whether the vehicle is moving fast, slowly or not at all. Attaching that draw to distance rather than to time makes a slow congested edge look cheap and a fast motorway edge look expensive, which is exactly backwards — the slow edge is occupied for longer and therefore costs more accessory energy.
Common Failure Patterns
1. Negative edge weights reaching Dijkstra. A regenerating segment has negative energy, and Dijkstra’s correctness proof requires non-negative weights — with a negative edge it can finalise a node before finding a cheaper path to it. Shift the whole scale so the minimum is zero, and subtract the constant back out of the total afterwards.
# The most negative any edge can be, computed once over the graph.
OFFSET_WH = 42.0
# Stored weight: never negative, so Dijkstra stays correct.
s.routing_wh = s.energy_wh + OFFSET_WH * (s.length_m / 1000.0)
# Reported total: the offset removed again, so the number means watt-hours.
total_wh = sum(routing_wh) - OFFSET_WH * (total_length_m / 1000.0)
2. Speed taken from the speed limit rather than from expected flow. Drag scales with the square of speed, so a segment costed at 130 km/h when traffic actually moves at 80 will report roughly two and a half times the aerodynamic component. Use the same expected-speed property the time-based cost uses, so the two models agree about the journey they are describing.
3. One vehicle profile baked into the graph. A van and a compact car differ by a factor of three in energy per kilometre, and writing energy_wh for one of them makes the graph unusable for the other. Store the geometry-derived quantities — grade_pct, length_m, expected speed — on the edge, and evaluate the vehicle-specific cost either at query time or into a per-profile property.
Performance Notes
The arithmetic is trivial; where the cost lands is in the write. Annotating every segment in a continental graph is a property write per relationship, which grows the store and invalidates cached pages:
$$C_{\text{annotate}} \approx R \cdot \big(c_{\text{read}} + c_{\text{write}}\big)$$
For a graph with tens of millions of relationships that is a substantial batch job, and it is worth doing in resumable chunks with periodic commits rather than one transaction. It is also worth doing only for the profiles that are actually served: writing four vehicle profiles onto every edge quadruples the property count for a benefit that only materialises if all four get used.
The alternative — computing energy at query time from the stored gradient — avoids the write entirely, keeps the store small, and supports unlimited profiles. Its cost is that the expression is not index-seekable, so it cannot be used to prune and it must be evaluated per candidate edge during the search. For a Graph Data Science projection that is fine, because the property can be computed into the projection once at build time and the search then runs against a precomputed array. That is generally the best arrangement: geometry in the store, vehicle costs in the projection, and the projection sized and dropped deliberately.
A word on validating the model rather than trusting it. Every constant above is an estimate, and the way to find out whether the estimate is any good is to compare predicted consumption against telemetry on trips that already happened. Summing energy_wh along a completed trip’s segments and comparing with the battery delta the vehicle actually reported gives a per-trip ratio; plotting that ratio against the trip’s total ascent separates the two failure modes cleanly. A ratio that is consistently off by a fixed factor means the mass or the drag estimate is wrong and a single scaling fixes it. A ratio that drifts with ascent means the gravity or regeneration handling is wrong, which is a structural problem no scaling will cure.
That comparison is also the honest way to set the regeneration efficiency, which is the constant with the widest range across vehicles and the one manufacturers are least specific about. Rather than picking a plausible number, fit it: the regeneration term only contributes on descending segments, so the residual between predicted and observed consumption on descent-heavy trips is almost entirely attributable to it. A few dozen trips is enough to pin it within a few points, and it is worth re-fitting seasonally, because cold weather moves it substantially.
Related
- Elevation and Terrain Enrichment for Routing Graphs — where
grade_pctcomes from and how it is validated. - Grade-Aware Weights for Bicycle Routing — the same gradient with a very different cost curve.
- Weighted Dijkstra Routing with Neo4j GDS — the search these weights feed, and why they must stay non-negative.
- Look-Ahead Corridors for In-Progress Trips — using an energy budget as a range horizon.
This guide is part of Elevation and Terrain Enrichment for Routing Graphs, within Spatial Graph Construction & OSM Ingestion.