Battery-electric delivery vehicles turn routing from a pure time-and-distance problem into an energy-constrained one: a route that is optimal on distance can strand a van at 4% state of charge three kilometers from the next stop. This guide is part of the Routing API Automation & Fleet Integration overview and builds the missing layer between a standard route solver and an EV fleet — a per-edge energy model, a charger index snapped to the routing graph, and a state-of-charge (SoC) tracker that inserts charging stops only when the route actually needs them. It assumes you already have a routable graph and a working route optimizer; the goal here is to wrap that optimizer with energy awareness rather than replace it.


SoC State Diagram with Charging-Stop Insertion State-of-charge line descending across delivery stops D1 through D4, crossing a reserve threshold before D3, triggering evaluation of three charger candidates, insertion of the nearest feasible charger, and a SoC recovery segment before the route continues to D3 and D4. 100% 0% SoC reserve threshold (20%) Depot D1 D2 threshold crossed 3 charger candidates ranked by detour cost + dwell time Charger Stop insertion D3 D4 SoC Along Route with Charging-Stop Insertion Insertion triggers only when the descent curve crosses the reserve threshold before the next feasible stop

Prerequisites

The pipeline below assumes a working route solver and a routable graph; this section adds the energy and charger layers on top.

System requirements

Resource Minimum Recommended
Python 3.9 3.11+
Routing graph Any routable OSM-derived graph (OSRM, Valhalla, or NetworkX DiGraph) Graph with grade_pct and length_m edge attributes precomputed
Charger data source Static CSV export Live OCPI/OCM feed with connector and power fields
RAM 4 GB for a metro-scale charger index 8 GB+ if indexing a national charger network

Python environment

# Install dependencies for energy modeling, charger indexing, and SoC tracking (Python 3.9+)
pip install numpy pandas scipy geopandas shapely pyproj

Data you need before starting


Conceptual Architecture

Charging-aware optimization adds three layers on top of a conventional route solver, each of which is covered in more depth on its own page: the energy model, the charger index, and the SoC-tracking pass that decides when to insert a stop.

The energy model converts each graph edge into a kWh cost instead of only a time cost. Distance is the dominant term, but grade, payload, and cabin/auxiliary draw all shift consumption by double-digit percentages on real routes. The modeling battery-range constraints for EV fleets page goes deep on this model, including regenerative braking credit and payload interaction terms; this section covers only the parts needed to run the pipeline end to end.

The charger index takes a raw charger feed — commonly an Open Charge Map (OCM) export or an internal fleet-depot inventory — and prepares it for routing: filtering by connector compatibility and minimum power, then snapping each charger to the nearest routable graph node so it can be inserted as a stop like any delivery location.

SoC tracking walks the planned route edge by edge, accumulating consumed energy into a running state-of-charge value. When the projected SoC at an upcoming stop would fall below a configured reserve threshold, the tracker triggers charger-candidate search and hands off to the insertion logic covered in inserting charging stops in route optimization. That page details detour-cost ranking and time-window feasibility; this guide shows the minimal version needed to understand the full loop.

These three layers sit downstream of whichever engine actually computes routes and durations — most fleets pair this with an async route matrix built against OSRM or Valhalla, since the SoC tracker needs distance and grade per edge, not just a single origin-destination duration.


Step-by-Step Implementation

1. Define the Vehicle Energy Profile

# requires: dataclasses (stdlib, Python 3.9+)
from dataclasses import dataclass


@dataclass(frozen=True)
class EVProfile:
    """Static per-vehicle-model energy characteristics."""
    battery_kwh: float          # usable capacity, e.g. 88.0 for an eSprinter
    base_kwh_per_km: float      # flat-road, unladen baseline consumption
    payload_kwh_per_km_per_t: float  # extra draw per tonne of payload
    regen_efficiency: float     # fraction of descent energy recovered, 0-1
    aux_draw_kw: float          # HVAC/refrigeration draw, converted via speed
    reserve_soc_pct: float = 20.0    # do not plan below this SoC
    max_charge_kw: float = 120.0     # DC fast-charge ceiling for this model


ESPRINTER_PROFILE = EVProfile(
    battery_kwh=88.0,
    base_kwh_per_km=0.31,
    payload_kwh_per_km_per_t=0.018,
    regen_efficiency=0.55,
    aux_draw_kw=1.8,
    reserve_soc_pct=20.0,
    max_charge_kw=115.0,
)

Keep the profile per vehicle model, not per fleet — mixed fleets should look up the correct EVProfile from a vehicle table keyed by vehicle_id before running the pipeline.

2. Compute Per-Edge Energy Cost

# requires: numpy, pandas (pip install numpy pandas)
import numpy as np
import pandas as pd


def compute_edge_energy_kwh(
    edges: pd.DataFrame,
    profile: "EVProfile",
    payload_tonnes: float = 0.0,
) -> pd.DataFrame:
    """
    Vectorized per-edge energy cost in kWh.

    edges columns required: edge_id, length_m, grade_pct, avg_speed_kmh
    Returns edges with an added `energy_kwh` column (may be negative on
    strong descents, representing net regen credit).
    """
    edges = edges.copy()
    length_km = edges["length_m"] / 1000.0

    # Baseline + payload consumption, flat-road equivalent
    base = (profile.base_kwh_per_km + profile.payload_kwh_per_km_per_t * payload_tonnes) * length_km

    # Grade term: uphill costs extra proportional to grade; downhill returns
    # a fraction of the potential energy via regen, capped by regen_efficiency.
    grade_term = np.where(
        edges["grade_pct"] > 0,
        base * (edges["grade_pct"] * 0.06),
        -base * (np.abs(edges["grade_pct"]) * 0.06) * profile.regen_efficiency,
    )

    # Auxiliary draw is time-based, not distance-based: derive travel time
    # from length and average speed, then convert aux_draw_kw to kWh.
    travel_hours = length_km / edges["avg_speed_kmh"].clip(lower=5.0)
    aux_kwh = profile.aux_draw_kw * travel_hours

    edges["energy_kwh"] = base + grade_term + aux_kwh
    return edges

3. Ingest and Index Charger Locations

# requires: pandas, geopandas, shapely, scipy (pip install pandas geopandas shapely scipy)
import pandas as pd
import geopandas as gpd
from scipy.spatial import cKDTree
import numpy as np


def build_charger_index(
    chargers_csv: str,
    min_power_kw: float = 50.0,
    connector_types: tuple[str, ...] = ("CCS", "CHAdeMO"),
) -> gpd.GeoDataFrame:
    """
    Load a raw charger feed (OCM-style export) and filter to fleet-compatible
    fast chargers. Returns a GeoDataFrame in EPSG:4326 with a projected
    EPSG:3857 xy pair cached for KD-tree nearest-node snapping.
    """
    df = pd.read_csv(chargers_csv)
    df = df[
        (df["power_kw"] >= min_power_kw)
        & (df["connector_type"].isin(connector_types))
    ].copy()

    gdf = gpd.GeoDataFrame(
        df,
        geometry=gpd.points_from_xy(df["lon"], df["lat"]),
        crs="EPSG:4326",
    ).to_crs("EPSG:3857")

    gdf["x"] = gdf.geometry.x
    gdf["y"] = gdf.geometry.y
    return gdf


def snap_chargers_to_graph(
    chargers: gpd.GeoDataFrame,
    graph_nodes: pd.DataFrame,
    max_snap_m: float = 300.0,
) -> pd.DataFrame:
    """
    Snap each charger to the nearest routable graph node using a KD-tree.

    graph_nodes columns required: node_id, x, y (same projected CRS as chargers)
    Rejects chargers farther than max_snap_m from any routable node.
    """
    tree = cKDTree(graph_nodes[["x", "y"]].to_numpy())
    dist, idx = tree.query(chargers[["x", "y"]].to_numpy(), k=1)

    chargers = chargers.copy()
    chargers["snapped_node_id"] = graph_nodes["node_id"].to_numpy()[idx]
    chargers["snap_distance_m"] = dist

    rejected = int((dist > max_snap_m).sum())
    if rejected:
        print(f"[warn] {rejected} chargers exceed {max_snap_m} m snap distance and were dropped")

    return chargers[chargers["snap_distance_m"] <= max_snap_m].reset_index(drop=True)

The 300-meter default snap distance mirrors the delivery-node snap tolerances used in optimizing node attributes for last-mile routing; tighten it in dense urban cores where a loose snap can place a charger on the wrong side of a divided highway.

4. Track State of Charge Along a Route

# requires: numpy, pandas (pip install numpy pandas)
import numpy as np
import pandas as pd


def track_soc(
    route_edges: pd.DataFrame,
    profile: "EVProfile",
    start_soc_pct: float,
) -> pd.DataFrame:
    """
    Accumulate SoC across an ordered sequence of route edges.

    route_edges columns required: edge_id, energy_kwh (from compute_edge_energy_kwh)
    Returns route_edges with soc_pct_start, soc_pct_end, and
    below_reserve (bool) columns.
    """
    route_edges = route_edges.copy()
    cum_kwh = route_edges["energy_kwh"].cumsum()

    start_kwh = (start_soc_pct / 100.0) * profile.battery_kwh
    remaining_kwh = start_kwh - cum_kwh

    route_edges["soc_pct_end"] = (remaining_kwh / profile.battery_kwh) * 100.0
    route_edges["soc_pct_start"] = route_edges["soc_pct_end"] + (
        route_edges["energy_kwh"] / profile.battery_kwh * 100.0
    )
    route_edges["below_reserve"] = route_edges["soc_pct_end"] < profile.reserve_soc_pct

    return route_edges


def first_threshold_crossing(tracked: pd.DataFrame) -> int | None:
    """Return the positional index of the first edge where SoC drops below reserve, or None."""
    crossing = tracked.index[tracked["below_reserve"]]
    return int(crossing[0]) if len(crossing) else None

5. Select and Score Charging-Stop Candidates

# requires: numpy, pandas (pip install numpy pandas)
import numpy as np
import pandas as pd


def rank_charger_candidates(
    chargers: pd.DataFrame,
    detour_matrix: pd.DataFrame,
    crossing_soc_pct: float,
    profile: "EVProfile",
    max_candidates: int = 3,
) -> pd.DataFrame:
    """
    Rank charger candidates near the SoC-threshold crossing by total cost:
    detour time plus estimated dwell time to reach a safe SoC.

    detour_matrix columns required: charger_id, detour_minutes
    (precomputed via the route optimizer's distance-matrix step, see
    async route matrix automation)
    """
    target_soc_pct = 80.0  # typical fast-charge stop target, avoids the tapering tail above 80%
    energy_needed_kwh = max(0.0, (target_soc_pct - crossing_soc_pct) / 100.0 * profile.battery_kwh)

    merged = chargers.merge(detour_matrix, on="charger_id", how="inner")
    merged["dwell_minutes"] = (energy_needed_kwh / merged["power_kw"].clip(upper=profile.max_charge_kw)) * 60.0
    merged["total_cost_minutes"] = merged["detour_minutes"] + merged["dwell_minutes"]

    return merged.sort_values("total_cost_minutes").head(max_candidates).reset_index(drop=True)

Detailed feasibility checks against delivery time windows — the part that decides whether the top-ranked candidate is actually usable — live in inserting charging stops in route optimization.

6. Splice the Stop into the Route

# requires: pandas (pip install pandas)
import pandas as pd


def insert_charging_stop(
    stop_sequence: list[str],
    insert_after_stop_id: str,
    charger_id: str,
) -> list[str]:
    """
    Insert a charger stop into an ordered stop-id sequence immediately
    after the stop where the SoC crossing was detected.
    """
    idx = stop_sequence.index(insert_after_stop_id)
    new_sequence = stop_sequence[: idx + 1] + [charger_id] + stop_sequence[idx + 1 :]
    return new_sequence

After insertion, re-run compute_edge_energy_kwh and track_soc on the modified sequence — the charger stop resets start_soc_pct for the remainder of the route to the target SoC used in candidate ranking (typically 80%), and downstream stops must be re-checked for both energy feasibility and time-window compliance.


Configuration Reference

Parameter Default Effect
reserve_soc_pct 20.0 SoC floor that triggers charging-stop search; raise for cold climates or aged battery packs
target_soc_pct (charge target) 80.0 Stop-and-charge target; charging above 80% is deliberately avoided due to tapering fast-charge rates
min_power_kw (charger filter) 50.0 Minimum charger power accepted as a fast-charge candidate; excludes Level 2 (AC) chargers from insertion search
max_snap_m 300.0 Maximum distance a charger may be from a routable node before it is dropped from the index
max_candidates 3 Number of ranked charger candidates evaluated for feasibility per threshold crossing
regen_efficiency 0.55 Fraction of descent potential energy recovered as regen credit; vehicle- and firmware-specific
payload_kwh_per_km_per_t 0.018 Marginal energy cost per tonne of payload per km, added to the base consumption rate
aux_draw_kw 1.8 Constant HVAC/refrigeration draw applied per hour of travel time, independent of distance

Production Optimization and Scaling

Precompute detour costs in batches, not per-crossing. Rather than querying the routing engine for a fresh detour matrix at every SoC-threshold crossing, precompute a charger-to-stop detour matrix for the day’s full stop list up front using the same async batching pattern as parallel distance matrix requests with aiohttp. This turns an O(routes × crossings) sequence of live API calls into a single batch job that runs once per planning cycle.

Cache the charger index, refresh status live. The charger location index (post-snapping) changes rarely — refresh it daily or on charger-network updates. Live status (occupied/offline) changes every few minutes and should be polled separately via OCPI or a vendor webhook, then joined onto the cached index at candidate-ranking time rather than baked into the index itself.

Vectorize SoC tracking across the whole fleet. For overnight batch planning across hundreds of routes, avoid looping route by route in Python. Group route_edges by route_id, sort by stop sequence, and use groupby().cumsum() for the energy accumulation step — this keeps track_soc a single vectorized pass instead of hundreds of function calls.

# requires: pandas (pip install pandas)
import pandas as pd


def track_soc_fleet(all_route_edges: pd.DataFrame, battery_kwh_by_route: dict) -> pd.DataFrame:
    """Vectorized SoC accumulation across many routes at once."""
    df = all_route_edges.sort_values(["route_id", "stop_sequence"]).copy()
    df["cum_kwh"] = df.groupby("route_id")["energy_kwh"].cumsum()
    df["battery_kwh"] = df["route_id"].map(battery_kwh_by_route)
    df["soc_pct_end"] = 100.0 * (1.0 - df["cum_kwh"] / df["battery_kwh"])
    return df

Set a fleet-wide reserve floor by season, not a single constant. Cold ambient temperatures reduce both range and fast-charge acceptance rates; the calibrating speed profiles for electric delivery fleets page’s seasonal multiplier table is a reasonable starting point for scaling reserve_soc_pct up in winter months rather than maintaining a single year-round value.

Bound charger search radius by planning horizon. For same-day dispatch, cap candidate search to chargers within a 15-minute detour; for next-day planning, a wider radius is acceptable since there is time to validate charger status closer to departure.


Validation and Testing

# requires: pandas, numpy (pip install pandas numpy)
import pandas as pd
import numpy as np

def check_soc_never_negative(tracked: pd.DataFrame) -> None:
    """Assert SoC never drops below zero across a tracked route."""
    assert (tracked["soc_pct_end"] >= 0).all(), "Route produces negative SoC — infeasible without a stop"
    print("[PASS] SoC remains non-negative across all edges")

def check_reserve_respected_after_insertion(tracked: pd.DataFrame, reserve_pct: float) -> None:
    """After inserting a charging stop, confirm SoC never drops below reserve again."""
    below = tracked[tracked["soc_pct_end"] < reserve_pct]
    assert below.empty, f"{len(below)} edges still drop below reserve after stop insertion"
    print("[PASS] Reserve threshold respected on all post-insertion edges")

def check_charger_snap_distances(chargers: pd.DataFrame, max_snap_m: float) -> None:
    """Assert no charger in the working index exceeds the configured snap tolerance."""
    assert (chargers["snap_distance_m"] <= max_snap_m).all(), "Charger index contains an over-tolerance snap"
    print(f"[PASS] All {len(chargers)} chargers within {max_snap_m} m of a routable node")

def check_energy_model_sane(edges: pd.DataFrame) -> None:
    """Sanity-check energy values: no absurd per-edge consumption."""
    per_km = edges["energy_kwh"] / (edges["length_m"] / 1000.0).clip(lower=0.01)
    assert per_km.between(-1.5, 2.5).all(), "energy_kwh/km outside plausible range for a delivery EV"
    print("[PASS] Per-km energy values within plausible bounds")

# Example run against a sample tracked route
# check_soc_never_negative(tracked_df)
# check_reserve_respected_after_insertion(tracked_df, reserve_pct=20.0)
# check_charger_snap_distances(charger_df, max_snap_m=300.0)
# check_energy_model_sane(edges_df)

Sanity metrics to monitor in production:

  • Track the gap between predicted soc_pct_end at each stop and telemetry-observed SoC; a mean absolute error creeping above 8% signals the energy model needs recalibration against recent CAN bus data.
  • Count charging-stop insertions per route per week; a sudden spike usually means either a charger-status feed went stale (candidates marked available but actually offline, forcing repeated re-planning) or a fleet composition change (degraded battery packs lowering effective capacity).
  • Log rejected charger candidates (out of time window, offline, incompatible connector) — a high rejection rate against a low max_candidates value means the candidate pool needs widening for that depot’s service area.

Troubleshooting

SoC tracker reports a threshold crossing that telemetry never confirms

The energy model is overestimating consumption, most commonly from a grade term reading the wrong sign convention (uphill treated as downhill) or a payload figure that was never cleared between routes. Audit grade_pct against known elevation profiles for a handful of edges, and confirm payload_tonnes resets to zero (or the correct residual load) at each delivery stop rather than persisting the full outbound load for the whole route.

Charging-stop insertion breaks downstream delivery time windows

The insertion step spliced the charger into the sequence without re-validating time windows for stops after it. Every insertion must trigger a full re-check of the remaining sequence’s dwell_minutes plus travel time against each downstream stop’s window — treat the charger as a stop with its own fixed service duration, not a free detour. See inserting charging stops in route optimization for the full feasibility-check sequence.

Charger snapping produces implausible nearest-node matches

This is almost always a CRS mismatch — the KD-tree query ran on unprojected lat/lon degrees instead of a projected CRS, so distance comparisons were meaningless across latitudes. Confirm both chargers and graph_nodes are in the same projected CRS (EPSG:3857 or a local UTM zone) before building the tree, and re-check max_snap_m is interpreted in meters, not degrees.

Fleet-wide vectorized SoC tracking gives different results than the per-route loop

Confirm the DataFrame is sorted by route_id then stop_sequence before calling groupby().cumsum() — an unsorted frame will accumulate energy in arbitrary edge order within a route, corrupting every soc_pct_end value downstream of the first misordered edge. Always sort explicitly rather than relying on input order.

Ranked charger candidates all fail feasibility despite chargers being physically available

The detour matrix is usually stale or was computed against a different stop sequence than the one currently being evaluated. Chargers ranked from an outdated detour_matrix can point to routes that no longer reflect the current insertion point. Recompute the detour matrix for the specific crossing point rather than reusing a matrix built earlier in the planning cycle, and confirm the charger status field was polled within the last few minutes before ranking.