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.

Energy per kilometre against gradient, and the kink where regeneration starts Watt-hours per kilometre plotted against road gradient for a 2,100 kilogram vehicle at 90 kilometres per hour. On the climbing side the curve rises steeply and almost linearly with gradient. Around minus one and a half per cent the gravity term cancels the rolling and aerodynamic resistances and the net demand reaches zero. Below that the vehicle begins recovering energy, but the recovered curve is much shallower than the climbing one, because the surplus passes back through both the regeneration and the drivetrain efficiencies. A symmetric model would mirror the climbing curve and overstate recovery by roughly a factor of two. Wh per km against gradient — 2,100 kg at 90 km/h 6004002000−200 −8%−4%0%+4%+8% gradient a symmetric model would follow this actual recovery — half as steep −1.5%: gravity cancels the resistances climbing The zero crossing is not at 0% gradient. A vehicle needs a slight descent before it stops drawing power at all, which is why a rolling road costs more than its net elevation change suggests.

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.

Three places the vehicle cost can be evaluated, and what each one costs A comparison of storing the energy cost as a property, computing it per query, and computing it once into a Graph Data Science projection. Storing it is fastest to read and cheapest at query time but adds a property per relationship per vehicle profile and has to be rewritten when anything changes. Computing per query costs nothing in store and supports unlimited profiles, but is evaluated on every candidate edge during the search and cannot prune. Computing into the projection pays once per projection build, supports a profile per projection, and lets the search run against a packed array. Where to evaluate the vehicle-specific cost stored as a relationship property fastest read · one property per profile per edge · rewritten on every change store grows per profile good when exactly one profile is served and the graph is stable computed per query from grade_pct no store cost · unlimited profiles · evaluated on every candidate edge not seekable, cannot prune good for ad-hoc analysis, poor for a hot routing endpoint computed into the GDS projection at build time paid once per projection · one profile per projection · search reads a packed array store stays geometry-only the usual answer for a service with a handful of profiles and a stable network

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.

This guide is part of Elevation and Terrain Enrichment for Routing Graphs, within Spatial Graph Construction & OSM Ingestion.