EV Model¶
Module: esfex.models.ev
S-Curve Growth Model¶
All EV profile functions use a logistic (S-curve) growth model [53] to project fleet size over time. The V2G (vehicle-to-grid) framework follows the fundamentals described in [51] and [52]:
Where:
- N(t) is the fleet growth factor at year t.
- N_max is the maximum growth multiplier (max_adoption parameter).
- k is the logistic growth rate (growth_rate parameter).
- t_mid is the midpoint year where adoption reaches 50% of maximum.
The midpoint is calculated as:
Each EV category can have its own max_adoption, growth_rate, and mid_point_fraction parameters, allowing different adoption curves for private cars, buses, trucks, etc.
Example growth curve:
For base_year=2025, target_year=2050, max_adoption=30, growth_rate=0.12, mid_point_fraction=0.5:
- 2025: growth_factor ~1.0 (initial fleet)
- 2037: growth_factor ~15.0 (midpoint)
- 2050: growth_factor ~29.5 (approaching maximum)
Functions¶
generate_ev_profiles¶
def generate_ev_profiles(
num_nodes: int,
num_hours: int,
ev_categories: Dict[str, dict],
ev_quantity: Dict[str, List[float]],
base_patterns: Dict[str, List[float]],
base_year: int = 2025,
target_year: int = 2050,
max_adoption: float = 30.0,
growth_rate: float = 0.12,
) -> pd.DataFrame
Generate EV charging demand profiles with S-curve fleet growth.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
num_nodes |
int |
required | Number of network nodes. |
num_hours |
int |
required | Total simulation hours (e.g., 25 * 8760 for 25 years). |
ev_categories |
dict |
required | EV category configurations. Keys are category names (e.g., "private_car", "bus", "truck"). Values are dicts with fields described below. |
ev_quantity |
dict |
required | Initial fleet count per category per node (base year). Keys are category names, values are lists of vehicle counts per node. |
base_patterns |
dict |
required | 24-hour charging availability templates per category. Keys are category names, values are lists of 24 floats in [0, 1] representing the fraction of fleet charging at each hour of day. |
base_year |
int |
2025 |
Fleet baseline year. |
target_year |
int |
2050 |
Current simulation year (used for S-curve calculation). |
max_adoption |
float |
30.0 |
Default maximum fleet growth multiplier (can be overridden per category). |
growth_rate |
float |
0.12 |
Default logistic growth rate (can be overridden per category). |
ev_categories dict fields:
| Key | Type | Description |
|---|---|---|
charging_power |
float |
Charging power per vehicle in kW. |
v2g_participation |
float |
Fraction of fleet participating in V2G (0-1). |
v2g_power |
float |
V2G discharge power per vehicle in kW. |
max_adoption |
float |
Category-specific max growth multiplier (optional, overrides default). |
growth_rate |
float |
Category-specific growth rate (optional, overrides default). |
mid_point_fraction |
float |
Fraction of total years at which adoption reaches 50% (optional, default 0.5). |
Returns: pd.DataFrame with:
- Index: hour indices (0 to num_hours - 1).
- Columns: "Node_{n}_{category}" for each node-category combination.
- Values: charging demand in MW.
Charging demand calculation:
Where num_vehicles = initial_quantity * growth_factor(year).
Small Gaussian noise (std=0.02) is added to the base pattern for realism.
Example:
from esfex.models.ev import generate_ev_profiles
ev_categories = {
"private_car": {
"charging_power": 7.4, # kW
"v2g_participation": 0.3,
"v2g_power": 5.0, # kW
"max_adoption": 30.0,
"growth_rate": 0.12,
},
"bus": {
"charging_power": 150.0,
"v2g_participation": 0.0,
"v2g_power": 0.0,
"max_adoption": 10.0,
"growth_rate": 0.08,
},
}
ev_quantity = {
"private_car": [5000, 3000, 2000, 1000], # vehicles per node
"bus": [50, 30, 20, 10],
}
# 24h charging pattern: home charging peaks evening, low during work hours
base_patterns = {
"private_car": [0.3, 0.2, 0.1, 0.1, 0.1, 0.1, 0.2, 0.3,
0.1, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.1,
0.2, 0.4, 0.6, 0.8, 0.9, 0.7, 0.5, 0.4],
"bus": [0.8, 0.8, 0.8, 0.8, 0.5, 0.1, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.1, 0.3, 0.5, 0.7, 0.8, 0.8],
}
profiles = generate_ev_profiles(
num_nodes=4, num_hours=8760,
ev_categories=ev_categories,
ev_quantity=ev_quantity,
base_patterns=base_patterns,
base_year=2025, target_year=2035,
)
print(profiles.columns.tolist())
# ['Node_1_private_car', 'Node_1_bus', 'Node_2_private_car', ...]
print(f"Peak charging: {profiles.max().max():.1f} MW")
generate_v2g_availability¶
def generate_v2g_availability(
num_nodes: int,
num_hours: int,
ev_categories: Dict[str, dict],
ev_quantity: Dict[str, List[float]],
base_patterns: Dict[str, List[float]],
base_year: int = 2025,
target_year: int = 2050,
max_adoption: float = 30.0,
growth_rate: float = 0.12,
) -> pd.DataFrame
Generate V2G (Vehicle-to-Grid) availability profiles with S-curve fleet growth.
Parameters are identical to generate_ev_profiles. V2G availability represents the fraction of the fleet available to discharge back to the grid, scaled by the v2g_participation rate and v2g_power per vehicle.
V2G availability calculation:
Returns: pd.DataFrame with V2G availability in MW. Same column naming as generate_ev_profiles.
aggregate_ev_profiles¶
Aggregate category-level EV profiles to node-level demand, summing across all categories at each node.
Parameters:
| Parameter | Type | Description |
|---|---|---|
profiles |
pd.DataFrame |
EV profiles from generate_ev_profiles() with columns "Node_{n}_{category}". |
num_nodes |
int |
Number of nodes. |
Returns: NumPy array of shape (hours, num_nodes) in MW. Each entry is the sum of all EV category charging demands at that node and hour.
Example:
from esfex.models.ev import generate_ev_profiles, aggregate_ev_profiles
profiles = generate_ev_profiles(num_nodes=4, num_hours=8760, ...)
aggregated = aggregate_ev_profiles(profiles, num_nodes=4)
# aggregated.shape == (8760, 4)
generate_electricity_prices¶
Generate synthetic electricity prices with morning and evening peak patterns.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
num_hours |
int |
24 |
Number of hours to generate. |
Returns: NumPy array of electricity prices in $/MWh. Range approximately 50-200 $/MWh with Gaussian noise (std=5).
The price curve uses two Gaussian peaks: - Morning peak at hour 9 (sigma=1.5) - Evening peak at hour 20 (sigma=2.0)
calculate_v2g_compensation¶
Calculate V2G compensation rates as 85% of electricity prices.
Returns: V2G compensation rates in $/MWh.
save_ev_profiles_hdf5¶
def save_ev_profiles_hdf5(
ev_charging: pd.DataFrame,
v2g_availability: pd.DataFrame,
filepath: Optional[str] = None,
) -> str
Save EV charging and V2G profiles to an HDF5 file for later reuse.
Parameters:
| Parameter | Type | Description |
|---|---|---|
ev_charging |
pd.DataFrame |
EV charging profiles from generate_ev_profiles(). |
v2g_availability |
pd.DataFrame |
V2G profiles from generate_v2g_availability(). |
filepath |
str or None |
Output path. If None, creates a temp file with a UUID name. |
Returns: Path to the created HDF5 file.
HDF5 structure:
ev_profiles_{uuid}.h5
charging/
data [hours x categories*nodes]
index [hours]
columns [category names]
v2g/
data [hours x categories*nodes]
index [hours]
columns [category names]
load_ev_profiles_hdf5¶
Load EV profiles from a previously saved HDF5 file.
Returns: Tuple of (ev_charging, v2g_availability) DataFrames.
Integration with Optimization¶
EV demand integrates into the power system in two ways:
-
Fixed demand: Add aggregated EV profiles to base demand via
total_demand = base_demand + ev_demand. Used when EV optimization is disabled. -
Optimizable V2G: Pass EV configuration to the optimizer via
ev_config_datainPowerSystemAdapter. The Julia model then creates EV charging/discharging decision variables with SOC tracking constraints. This allows V2G to provide grid services (peak shaving, frequency regulation).
When EV optimization is enabled, EV demand should NOT be added to total_demand to avoid double-counting.