Fleet dispatch systems fail quietly when they treat electric-vehicle range as a flat distance cutoff instead of an accumulating energy budget. This page is the modeling layer underneath EV fleet charging-aware route optimization, the section of Routing API Automation & Fleet Integration that covers energy models, charger snapping, and stop insertion for delivery EVs — it focuses narrowly on producing a trustworthy per-edge energy cost and state-of-charge trace, which every other technique in that section depends on.
Where inserting charging stops in route optimization decides where to detour once a battery is running low, this page addresses what feeds that decision: converting road-network geometry, grade, and payload into kilowatt-hours per edge, accumulating that into a state-of-charge trace, and pruning the graph to the set of nodes actually reachable before a safety reserve is breached. Get the energy model wrong and every downstream charging decision — early, late, or entirely missed — inherits the error.
When to Use This Approach
A flat range cutoff (for example, “exclude stops beyond 120 km”) is adequate only for near-flat service areas with uniform payload and mild climate. Build an explicit energy-per-edge model instead when any of the following apply:
- The service area has meaningful elevation change — hilly last-mile zones or regional line-haul routes where climbs and descents are a large fraction of total distance.
- Payload varies materially between routes or across a single multi-stop route as parcels are delivered and the vehicle gets lighter.
- The fleet mixes vehicle classes (cargo van, box truck, cargo bike) with different mass, drag, and drivetrain efficiency, so a single range number per vehicle type is wrong for the rest.
- Dispatch needs a defensible answer to “how much reserve is left at stop 7,” not just “did the route fit under the range limit.”
- You are about to build charging-stop insertion and need a continuous state-of-charge signal to trigger detours against, rather than a binary in-range/out-of-range flag.
If none of these apply — a single vehicle type, flat terrain, fixed payload, short routes well inside rated range — a flat distance buffer is simpler and the energy model below is unnecessary engineering overhead.
Implementation
The model has two stages: a vectorized per-edge energy function, then a cumulative state-of-charge trace with reachability pruning. Both operate on NumPy arrays so they scale to graphs with hundreds of thousands of edges without a Python-level loop.
# requires: numpy>=1.24 (pip install numpy)
# Python 3.9+
import numpy as np
G_ACCEL = 9.81 # m/s^2
AIR_DENSITY = 1.225 # kg/m^3 at sea level, 15C
def edge_energy_kwh(
distance_m: np.ndarray,
grade_pct: np.ndarray,
speed_kmh: np.ndarray,
payload_kg: np.ndarray,
vehicle_mass_kg: float,
frontal_area_m2: float = 4.2,
drag_coeff: float = 0.65,
rolling_coeff: float = 0.0095,
drivetrain_eff: float = 0.88,
regen_eff: float = 0.55,
aux_power_kw: float = 1.8,
) -> np.ndarray:
"""Vectorized per-edge traction + auxiliary energy cost, in kWh.
All array arguments share one row per graph edge. Positive grade_pct
is climbing; negative is descending, where recovered energy is capped
by regen_eff and a hardware recovery limit below.
"""
mass = vehicle_mass_kg + payload_kg # kg, broadcasts per edge
theta = np.arctan(grade_pct / 100.0) # radians
speed_ms = speed_kmh / 3.6
f_grade = mass * G_ACCEL * np.sin(theta) # N
f_roll = mass * G_ACCEL * rolling_coeff * np.cos(theta)
f_aero = 0.5 * AIR_DENSITY * drag_coeff * frontal_area_m2 * speed_ms ** 2
f_total = f_grade + f_roll + f_aero # N, signed
work_j = f_total * distance_m # Joules, signed
traction_kwh = np.where(
work_j >= 0,
work_j / drivetrain_eff, # motoring: efficiency loss
work_j * regen_eff, # braking: efficiency gain (negative)
) / 3.6e6
travel_time_h = (distance_m / 1000.0) / np.maximum(speed_kmh, 1e-6)
aux_kwh = aux_power_kw * travel_time_h # HVAC, telematics, refrigeration
edge_kwh = traction_kwh + aux_kwh
# Hardware-limited regen recovery cap — batteries and inverters throttle
# charge acceptance; a descent cannot recover more than this fraction
# of the potential energy released.
regen_cap_kwh = -0.35 * (mass * G_ACCEL * distance_m) / 3.6e6
return np.maximum(edge_kwh, regen_cap_kwh)
The second stage accumulates energy cost into a state-of-charge trace and derives the reachable subset of an ordered node sequence — typically the output of a single-source shortest-path pass where weight is edge_kwh rather than distance:
# requires: numpy>=1.24 (pip install numpy)
# Python 3.9+
def soc_trace(
edge_kwh: np.ndarray,
battery_capacity_kwh: float,
start_soc_pct: float = 100.0,
) -> np.ndarray:
"""Cumulative state-of-charge (%) after each edge along an ordered route.
Negative values are left unclipped on the low end so infeasible legs
are visible to validation code rather than silently masked.
"""
cum_kwh = np.cumsum(edge_kwh)
soc_pct = start_soc_pct - (cum_kwh / battery_capacity_kwh) * 100.0
return np.minimum(soc_pct, 100.0)
def reachable_mask(
edge_kwh: np.ndarray,
remaining_kwh: float,
reserve_kwh: float,
) -> np.ndarray:
"""Boolean mask over an ordered edge sequence: True while cumulative
energy stays within the usable budget above the safety reserve.
Intended for use against nx.single_source_dijkstra_path_length(G,
source, weight="energy_kwh") output, sorted by cumulative cost, so the
mask marks the reachable frontier before energy is exhausted.
"""
cum_kwh = np.cumsum(edge_kwh)
budget_kwh = remaining_kwh - reserve_kwh
return cum_kwh <= budget_kwh
# Example: six-stop delivery leg, moderate climb then a long descent
distances = np.array([1800, 2400, 1600, 3100, 2000, 2600]) # meters
grades = np.array([1.5, 3.2, 4.8, -2.0, -5.5, 0.4]) # percent
speeds = np.array([40, 35, 25, 45, 50, 40]) # km/h
payload = np.array([620, 590, 540, 480, 430, 430]) # kg, decreasing as stops complete
costs = edge_energy_kwh(distances, grades, speeds, payload, vehicle_mass_kg=2400)
soc = soc_trace(costs, battery_capacity_kwh=62.0, start_soc_pct=88.0)
reachable = reachable_mask(costs, remaining_kwh=0.88 * 62.0, reserve_kwh=0.15 * 62.0)
print("edge_kwh:", np.round(costs, 3))
print("soc_pct :", np.round(soc, 1))
print("reachable:", reachable)
Both functions are pure NumPy — no Python-level iteration over edges — so applying edge_energy_kwh across an entire OSM-derived edge table with pandas is a single vectorized call rather than a DataFrame.apply() loop, which matters once the graph has more than a few thousand edges.
Key Parameters and Tuning
| Parameter | Typical range | Effect |
|---|---|---|
vehicle_mass_kg |
1,800–3,500 (delivery van class) | Scales both grade and rolling-resistance force linearly |
payload_kg |
0–1,500, decreasing along a delivery route | Same linear scaling as vehicle mass; recompute per remaining-stop segment |
frontal_area_m2 |
3.5–6.5 | Enters aerodynamic drag quadratically with speed — matters most on highway legs |
drag_coeff (Cd) |
0.55–0.75 for cargo vans | Manufacturer test data if available; otherwise use class-average estimates |
rolling_coeff (Cr) |
0.008–0.012 | Sensitive to tire pressure and load; recalibrate seasonally |
drivetrain_eff |
0.85–0.92 | Combined motor, inverter, and gearbox efficiency under motoring load |
regen_eff |
0.45–0.65 | Conservative default; verify against telemetry before trusting regen credit |
aux_power_kw |
0.8–4.5 | HVAC and refrigeration dominate in extreme temperatures — model seasonally, not as a constant |
reserve_kwh / reserve % |
10–20% of usable capacity | Safety margin held back before a route is flagged for a charging stop |
battery_capacity_kwh (usable) |
60–120 for last-mile vans | Usable capacity, not nameplate — apply a state-of-health derating factor for aging packs |
Grade sign convention. Positive grade_pct must mean climbing throughout the pipeline. If grade is derived from a DEM by subtracting elevation at edge endpoints, verify the sign matches the direction of travel used elsewhere in the graph — a graph traversed in both directions needs grade recomputed per direction, not a single signed value copied to the reverse edge.
Payload decay along a route. For multi-stop delivery runs, payload should decrease stop by stop as parcels are dropped, not stay fixed at the loaded weight for the whole route. Recompute payload_kg per edge from the manifest rather than using a single average, particularly on routes with a few heavy pallets delivered early.
Integration Points
The energy model produces a per-edge cost that plugs into the same graph structures used elsewhere on this site, alongside distance and time weights rather than replacing them:
NetworkX. Store edge_kwh as an edge attribute (G.edges[u, v]["energy_kwh"] = ...) parallel to the weights used in NetworkX shortest path algorithms for logistics. Running nx.single_source_dijkstra_path_length(G, source, weight="energy_kwh") gives cumulative energy cost to every node in one pass, which is the direct input to reachable_mask above — this is the energy-domain equivalent of isochrone construction, substituting kWh for travel time.
OSRM and Valhalla. Neither engine has a native battery-energy cost model, so the energy-constrained subgraph is computed in Python first, then only the reachable node set is passed downstream — either as a bounding filter on candidate waypoints or as a hard exclusion list before requesting turn-by-turn geometry. This mirrors how Valhalla configuration for multi-modal analysis layers custom costing on top of a general-purpose engine rather than inside it.
Charging-stop insertion. The soc_trace output is the trigger signal consumed by inserting charging stops in route optimization: whenever the trace crosses the reserve threshold, that stage searches for a feasible charger detour before the crossing point, rather than after the vehicle is already stranded.
Speed inputs. The speed_kmh array should come from calibrated speed profiles rather than posted limits, since real-world speed strongly affects both aerodynamic drag and travel time. See speed profile calibration for heavy vehicles and, for the EV-specific case, calibrating speed profiles for electric delivery fleets, which covers acceleration-heavy urban stop-and-go patterns that this energy model treats as a constant speed_kmh per edge — a simplification worth revisiting if urban energy estimates run consistently high.
Validation Checklist
-
Flat-road sanity check. With
grade_pct = 0and payload at the manufacturer’s test configuration,edge_energy_kwhsummed over 100 km should land within roughly 10% of the published WLTP or EPA consumption figure (kWh/100 km). Larger deviations point to a wrongdrivetrain_efforCdvalue. -
Regen cap enforcement. For any edge with
grade_pct < 0, assertedge_kwh >= regen_cap_kwhand that no single descent produces a state-of-charge gain exceeding what the regen cap allows over that edge’s distance. -
State-of-charge floor.
reachable_maskmust never mark a node reachable when its cumulative energy cost exceedsremaining_kwh - reserve_kwh. Test this directly with a synthetic route engineered to cross the reserve mid-route. -
Telemetry cross-check. On a handful of completed real-world routes with logged SoC, compare
soc_traceoutput against the actual telemetry curve. Mean absolute error under roughly 3 percentage points across a route is a reasonable production bar; larger errors usually trace back to grade resolution or an uncalibratedaux_power_kw. -
Payload sensitivity. Holding grade and speed fixed, scaling
payload_kgfrom 0 to the vehicle’s rated maximum should produce a monotonic, roughly linear increase inedge_kwhon flat terrain. A non-monotonic result indicates a sign error in the force terms. -
Cross-vehicle regression. Running the same route through two vehicle profiles (for example, a cargo van and a cargo bike) should produce SoC deltas whose direction and rough magnitude match the difference in mass and drag coefficient — a useful smoke test before trusting the model across a mixed fleet.
Why does modeled state-of-charge diverge from telemetry on hilly routes?
The most common cause is a grade sign-convention error or a grade signal that is too coarse. If edge grade is derived from a low-resolution DEM sampled only at endpoints, short steep pitches inside a long edge get averaged away. Resample grade at a finer interval (50-100 m) along each edge and re-aggregate before computing energy cost.
Why does the reachable node set shrink to almost nothing near the depot?
This usually means reserve_kwh is set too high relative to usable battery_capacity_kwh, or aux_power_kw is overstated for the ambient temperature being modeled. Check that the reserve is expressed as a percentage of usable (not nameplate) capacity, and that auxiliary load assumptions match the season being simulated rather than a worst-case winter default year-round.
Should regenerative braking ever push modeled state-of-charge above 100%?
No. Regenerated energy must be clipped both by a hardware-limited recovery cap per edge and by an upper state-of-charge bound, since battery management systems throttle charge acceptance sharply above roughly 90% SoC. A model that lets a long descent push SoC past 100% is missing both caps and will understate energy consumption downstream.
Related
- EV fleet charging-aware route optimization — the parent guide covering energy models, charger snapping, and stop insertion together
- Inserting charging stops in route optimization — using the state-of-charge trace built here as the trigger for detour insertion
- Speed profile calibration for heavy vehicles — calibrated speed inputs that feed the aerodynamic and time-based terms of the energy model
- Calibrating speed profiles for electric delivery fleets — EV-specific acceleration and stop-and-go patterns that refine the constant-speed simplification used here