Electric delivery fleets expose a fundamental mismatch between static maxspeed tags and the physics of battery-electric drivetrains. Unlike diesel vans where torque is broadly constant across load ranges, EV motors hit inverter current limits under simultaneous payload and grade stress, producing non-linear speed degradation that a fixed tag cannot capture. This page covers the specific calibration variant required for electric delivery vehicles — modelling regenerative braking, state-of-charge (SoC) thermal throttling, and payload-adjusted torque caps as dynamic edge weights. It sits within the broader framework of speed profile calibration for heavy vehicles and feeds directly into the OSM graph architecture and network modeling pipeline that assigns cost functions to directed graph edges.
When to Use This Approach
Use EV-specific calibration rather than a generic heavy-vehicle multiplier when all three of the following conditions apply:
- Fleet composition is >30% battery-electric. Below this threshold, blending EV and ICE profiles produces acceptable accuracy. Above it, undifferentiated profiles systematically underestimate travel time on graded urban routes.
- Delivery zones include roads with grade ≥ 2%. Flat urban cores with <1% average grade see negligible difference between EV and ICE profiles. Hilly terrain (>2% average grade, or any segment >6%) triggers the grade-penalty and regen-allowance branches that are exclusive to this calibration.
- You have CAN bus or OBD-II telemetry alongside GPS. Payload, SoC, and stop-frequency inputs require synchronized onboard data. If only GPS traces are available, skip the
soc_factorandpayload_factorand fall back to grade-only correction.
This approach applies to Class 2b–4 electric delivery vans (3,500–7,250 kg GVWR) including common platforms such as the Ford E-Transit, Rivian EDV, and Mercedes eSprinter. For Class 7–8 electric trucks, the inverter thermal models and torque-curve shapes differ materially; use manufacturer-specific dyno data rather than the fleet-average coefficients below.
Pipeline Trigger Decision
The diagram below shows how incoming telemetry determines which calibration branch executes:
Implementation
The calibration function below is self-contained and intentionally avoids replicating the data-loading boilerplate covered in the speed profile calibration for heavy vehicles cluster. It assumes the caller has already joined elevation data and maxspeed lookups onto the edge DataFrame.
# Requires: numpy>=1.24, pandas>=2.0
import numpy as np
import pandas as pd
def calibrate_ev_speeds(
df: pd.DataFrame,
base_speed_col: str = "base_speed_kmh",
grade_col: str = "grade_pct",
payload_col: str = "payload_kg",
soc_col: str = "soc_pct",
stop_freq_col: str = "stops_per_km",
degradation_factor: float = 1.0,
) -> pd.DataFrame:
"""
Apply EV-specific correction multipliers to OSM base road speeds.
Parameters
----------
df : DataFrame with columns [edge_id, base_speed_kmh, grade_pct,
payload_kg, soc_pct, stops_per_km]
base_speed_col : column containing maxspeed-derived baseline (km/h)
grade_col : road grade in percent (positive = uphill)
payload_col : cargo mass in kg (excluding vehicle kerb weight)
soc_col : battery state-of-charge in percent [0–100]
stop_freq_col : number of scheduled delivery stops per km
degradation_factor : multiplier for battery capacity loss (default 1.0 = new;
0.90 = ~80k km wear; 0.82 = ~160k km)
Returns
-------
DataFrame with [edge_id, calibrated_speed_kmh, time_cost_min_per_km]
"""
df = df.copy()
# --- 1. Payload penalty ---------------------------------------------------
# Each 100 kg of cargo on a graded segment reduces speed by ~4%.
# Interaction term (payload × |grade|) captures the non-linear motor-load
# relationship; flat segments are unaffected regardless of payload.
payload_factor = 1.0 - (df[payload_col] * 0.0004 * np.abs(df[grade_col]))
payload_factor = payload_factor.clip(lower=0.60) # floor at 60% of base
# --- 2. Grade factor (uphill drag + downhill regen) ----------------------
# Uphill: exponential penalty matches measured EV energy curves.
# Downhill: small regen allowance (+1.2 % speed per % negative grade)
# capped at +8 % above base to avoid unrealistic approach speeds.
grade_factor = np.where(
df[grade_col] > 0,
np.exp(-0.035 * df[grade_col]),
(1.0 + 0.012 * np.abs(df[grade_col])).clip(upper=1.08),
)
# --- 3. SoC thermal throttle --------------------------------------------
# Below 20 % SoC the BMS limits peak current to protect cell longevity.
# Linear ramp: 0.75× at 0 % → 1.0× at 20 %; full speed above 20 %.
# Apply degradation_factor to tighten the throttle onset for aged packs.
effective_soc = df[soc_col] * degradation_factor
soc_factor = np.where(
effective_soc < 20,
0.75 + (0.0125 * effective_soc),
1.0,
)
# --- 4. Urban stop-and-go penalty ----------------------------------------
# Each stop per km costs ~2.5 % of cruising speed in acceleration energy.
# Clamped at 0.5 to prevent negative values on very dense stop sequences.
urban_factor = (1.0 - (df[stop_freq_col] * 0.025)).clip(lower=0.50)
# --- 5. Combine and clamp ------------------------------------------------
# Product of all factors, bounded between 5 km/h (urban crawl) and the
# posted limit. The upper bound prevents regen allowances from exceeding
# regulatory maximums on downhill segments.
df["calibrated_speed_kmh"] = (
df[base_speed_col]
* payload_factor
* grade_factor
* soc_factor
* urban_factor
).clip(lower=5.0, upper=df[base_speed_col])
# --- 6. Time-cost conversion ---------------------------------------------
# Routing engines (OSRM, Valhalla, GraphHopper) optimize on duration,
# not raw speed. Convert here so the output is engine-ready.
df["time_cost_min_per_km"] = 60.0 / df["calibrated_speed_kmh"]
return df[["edge_id", "calibrated_speed_kmh", "time_cost_min_per_km"]]
Seasonal Adjustment Wrapper
Battery thermal performance degrades in cold weather independently of SoC. Wrap the core function with a seasonal multiplier keyed to the fleet’s operating region:
# Requires: numpy>=1.24, pandas>=2.0
import numpy as np
import pandas as pd
TEMP_SPEED_MULTIPLIERS = {
# Ambient temp range (°C) → fractional speed retention
"below_minus10": 0.82,
"minus10_to_0": 0.90,
"0_to_10": 0.96,
"above_10": 1.00,
}
def seasonal_ev_calibration(
df: pd.DataFrame,
ambient_celsius: float,
**kwargs,
) -> pd.DataFrame:
"""
Wraps calibrate_ev_speeds with a temperature-based speed multiplier.
Pass any calibrate_ev_speeds keyword arguments via **kwargs.
"""
if ambient_celsius < -10:
temp_factor = TEMP_SPEED_MULTIPLIERS["below_minus10"]
elif ambient_celsius < 0:
temp_factor = TEMP_SPEED_MULTIPLIERS["minus10_to_0"]
elif ambient_celsius < 10:
temp_factor = TEMP_SPEED_MULTIPLIERS["0_to_10"]
else:
temp_factor = TEMP_SPEED_MULTIPLIERS["above_10"]
result = calibrate_ev_speeds(df, **kwargs)
result["calibrated_speed_kmh"] = (
result["calibrated_speed_kmh"] * temp_factor
).clip(lower=5.0)
result["time_cost_min_per_km"] = 60.0 / result["calibrated_speed_kmh"]
return result
Key Parameters and Tuning
| Parameter | Default | Recommended Range | Sensitivity Notes |
|---|---|---|---|
| Payload penalty coefficient | 0.0004 | 0.0003–0.0006 | Increase for vans with narrow torque bands (e.g. early 2022 E-Transit); decrease for dual-motor platforms |
| Grade uphill exponent | −0.035 | −0.025 to −0.055 | Higher magnitude = steeper speed drop on grades; calibrate from dyno data or >500 matched GPS/grade pairs |
| Regen allowance per grade-% | 0.012 | 0.008–0.018 | Conservative for 1-pedal driving disabled; increase for fleets with aggressive regen settings |
| Regen speed cap | 1.08× | 1.04–1.10× | Never exceed 1.10× — approach speed headroom above baseline is limited by posted limits |
| SoC throttle floor | 0.75 | 0.70–0.85 | Lower for older BMS firmware that throttles more aggressively; raise for newer packs with less conservative management |
| SoC throttle onset | 20% | 15–25% | Fleet telematics mean SoC at throttle activation; raise for degraded packs |
| Urban stop penalty per stop/km | 0.025 | 0.018–0.035 | Higher for steep acceleration zones (hills + traffic lights); calibrate from actual dwell-time logs |
| Urban floor | 0.50 | 0.40–0.60 | Minimum fraction of base speed in the densest stop sequences |
| Cold-weather floor (< −10 °C) | 0.82 | 0.78–0.86 | Vary by chemistry: LFP retains more range in cold than NMC |
degradation_factor |
1.0 | 0.82–1.0 | 0.90 ≈ 80k km; 0.82 ≈ 160k km; source from fleet health dashboard |
Grade bins that inform the penalty branches should be set at ±2%, ±4%, and >±6%. Segments steeper than ±6% must be checked against local maxspeed:practical or incline OSM tags — the synthetic downhill regen allowance should be suppressed on road classes where speed is legally restricted regardless of grade.
Integration Points
The calibrate_ev_speeds output DataFrame (columns: edge_id, calibrated_speed_kmh, time_cost_min_per_km) connects to three common open-source routing engines:
OSRM (Lua profile override). Write calibrated_speed_kmh to a lookup CSV indexed by OSM way ID. In the Lua profile, override speed_for_way to query this CSV and return the calibrated value. Rebuild the graph with osrm-extract and osrm-contract. This replaces the default maxspeed parsing in the car and HGV profiles. See integrating custom traffic weights into OSRM for the full injection pipeline.
Valhalla (costing JSON). Map time_cost_min_per_km to the speed field in a custom auto or truck costing model. Valhalla’s tile-based graph allows per-edge speed overrides without full graph recompilation, making it well suited to nightly telemetry updates. The Valhalla configuration for multi-modal analysis cluster covers tile structure and costing JSON schema in detail.
GraphHopper (custom Weighting class). Implement a Java Weighting that reads the calibrated speed from an edge property store populated at graph-import time. The getMinWeight method should use the maximum observed calibrated speed across all EV edges to maintain A* admissibility.
NetworkX (direct edge attribute). For prototype and analysis workloads, write time_cost_min_per_km as the weight attribute on each DiGraph edge. NetworkX’s dijkstra_path_length and shortest_path use this attribute directly. The NetworkX shortest-path algorithms for logistics cluster explains directed graph construction from OSM data.
For production fleets, schedule a nightly Airflow or Prefect task that re-runs calibrate_ev_speeds over the prior 24 hours of telemetry, diffs the result against the current edge table, and only triggers a graph rebuild if more than 5% of edges changed by more than 8%. This avoids full recompiles for minor telemetry fluctuations.
Validation Checklist
Run these checks after each calibration cycle before promoting updated weights to the production routing engine:
-
Speed bounds check. Assert
calibrated_speed_kmh.between(5.0, base_speed_kmh).all(). Any edge below 5 km/h or above its posted limit indicates a coefficient out of range or a data join failure. -
Grade-speed correlation. Group edges by grade bin (±2%, ±4%, >±6%) and verify that mean
calibrated_speed_kmhis monotonically decreasing with uphill grade. Inversion signals a sign error in thegrade_coldata (common when SRTM elevation is subtracted in the wrong direction). -
SoC factor range. Filter edges with
soc_pct < 20and confirmsoc_factorvalues fall between 0.75 and 1.0. Values outside this range indicate a units mismatch (e.g. SoC supplied as a 0–1 fraction rather than 0–100 percent). -
Route-level time deviation audit. For each completed delivery route in the validation window, compare
sum(time_cost_min_per_km × edge_length_km)against actual GPS-observed route duration. Flag routes with absolute deviation >12%. Investigate flagged routes for mismatchededge_idjoins or missing elevation data before adjusting coefficients. -
Energy consistency check. Spot-check 50 edges: verify that edges with higher
time_cost_min_per_km(slower, penalized segments) also appear in the top quartile of observed kWh/km from CAN bus logs. A strong rank correlation (Spearman ρ > 0.75) confirms the speed penalties align with actual energy draw. -
Degradation factor audit. Once per quarter, pull fleet-wide battery health reports and compare
degradation_factorvalues in the calibration config against measured usable capacity. A gap >5 percentage points between config and measured health requires an immediate coefficient update to prevent systematic route underestimation for high-mileage vehicles.
Related
- Speed profile calibration for heavy vehicles — grade bins, payload tiers, and OSM tag mapping
- Configuring edge weights for freight logistics — cost function design and vehicle-class mapping
- Integrating custom traffic weights into OSRM — Lua profile injection and graph rebuild pipeline
- Valhalla configuration for multi-modal analysis — costing JSON schema and tile-level speed overrides