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.
Payload decay is worth modelling explicitly rather than averaging, because the error it introduces is not symmetric. Holding payload at the loaded weight for the whole run overstates consumption on every leg after the first heavy drop, which quietly inserts charging stops the vehicle never needed.
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