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.
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
- A vehicle energy profile: battery capacity in kWh, baseline consumption in kWh/km, and a charge curve (kW accepted vs. SoC%).
- A charger dataset with at minimum
charger_id,lat,lon,connector_type,power_kw, and ideally a livestatusfield. - Edge-level
length_mandgrade_pcton your routing graph — the same fields used by speed profile calibration for heavy vehicles and its electric-fleet variant, calibrating speed profiles for electric delivery fleets.
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_endat 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_candidatesvalue 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.
Related
- Inserting charging stops in route optimization — detour-cost ranking, time-window feasibility, and splice logic in full detail
- Modeling battery-range constraints for EV fleets — the energy model extended with reachability pruning and regen detail
- Calibrating speed profiles for electric delivery fleets — SoC-aware speed throttling that feeds duration estimates into this energy model
- Speed profile calibration for heavy vehicles — the general grade and payload calibration framework this guide builds on
- Async route matrix automation — precomputing the detour matrices that charger-candidate ranking depends on