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.

Edge energy model and range-based reachability pruning Left panel shows an edge between two nodes with distance, grade, and payload feeding an energy formula that outputs kilowatt-hours. Right panel shows a state-of-charge trace declining across six route stops, with a dashed reserve threshold; stops after the trace crosses the threshold are marked as pruned and excluded from the reachable set. Per-edge energy cost distance d grade θ payload m A B E = f(d, θ, m, v) − E_regen traction + auxiliary load, regen-capped ΔkWh SoC trace & range pruning 100% 0% reserve threshold × × depot reachable set pruned — exceeds range

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

  1. Flat-road sanity check. With grade_pct = 0 and payload at the manufacturer’s test configuration, edge_energy_kwh summed over 100 km should land within roughly 10% of the published WLTP or EPA consumption figure (kWh/100 km). Larger deviations point to a wrong drivetrain_eff or Cd value.

  2. Regen cap enforcement. For any edge with grade_pct < 0, assert edge_kwh >= regen_cap_kwh and that no single descent produces a state-of-charge gain exceeding what the regen cap allows over that edge’s distance.

  3. State-of-charge floor. reachable_mask must never mark a node reachable when its cumulative energy cost exceeds remaining_kwh - reserve_kwh. Test this directly with a synthetic route engineered to cross the reserve mid-route.

  4. Telemetry cross-check. On a handful of completed real-world routes with logged SoC, compare soc_trace output 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 uncalibrated aux_power_kw.

  5. Payload sensitivity. Holding grade and speed fixed, scaling payload_kg from 0 to the vehicle’s rated maximum should produce a monotonic, roughly linear increase in edge_kwh on flat terrain. A non-monotonic result indicates a sign error in the force terms.

  6. 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.