#[derive(Debug, Clone)]
pub struct EvFleetProfile {
pub total_evs: usize,
pub avg_battery_kwh: f64,
pub avg_daily_km: f64,
pub energy_per_km_kwh: f64,
pub home_charging_fraction: f64,
pub workplace_charging_fraction: f64,
pub public_charging_fraction: f64,
pub adoption_rate_pct: f64,
}
#[derive(Debug, Clone)]
pub struct EvGridImpact {
pub feeder_id: usize,
pub transformer_kva: f64,
pub feeder_peak_mw: f64,
pub base_load_profile_mw: Vec<f64>,
pub ev_fleet: EvFleetProfile,
}
impl EvGridImpact {
pub fn daily_energy_demand_kwh(&self) -> f64 {
let f = &self.ev_fleet;
f.total_evs as f64 * f.avg_daily_km * f.energy_per_km_kwh * f.adoption_rate_pct / 100.0
}
pub fn uncontrolled_charging_profile(&self) -> Vec<f64> {
let mut profile = vec![0.0_f64; 24];
let energy_home_kwh = self.daily_energy_demand_kwh() * self.ev_fleet.home_charging_fraction;
let power_per_hour_kw = energy_home_kwh / 4.0;
let power_per_hour_mw = power_per_hour_kw / 1_000.0;
for item in profile.iter_mut().take(22).skip(18) {
*item = power_per_hour_mw;
}
profile
}
pub fn peak_demand_increase_mw(&self) -> f64 {
self.uncontrolled_charging_profile()
.into_iter()
.fold(0.0_f64, f64::max)
}
pub fn transformer_loading_pct(&self, load_mw: f64) -> f64 {
if self.transformer_kva <= 0.0 {
return 0.0;
}
load_mw * 1_000.0 / self.transformer_kva * 100.0
}
pub fn hosting_capacity_evs(&self, max_loading_pct: f64) -> usize {
let max_load_mw = max_loading_pct / 100.0 * self.transformer_kva / 1_000.0;
let headroom_mw = (max_load_mw - self.feeder_peak_mw).max(0.0);
let energy_per_ev_kwh = self.ev_fleet.avg_daily_km
* self.ev_fleet.energy_per_km_kwh
* self.ev_fleet.adoption_rate_pct
/ 100.0;
let avg_power_per_ev_mw = energy_per_ev_kwh / 4.0 / 1_000.0;
if avg_power_per_ev_mw <= 0.0 {
return 0;
}
(headroom_mw / avg_power_per_ev_mw).floor() as usize
}
pub fn voltage_impact_estimate_pct(&self, impedance_pu: f64) -> f64 {
let p_pu = self.peak_demand_increase_mw() / self.transformer_kva * 1_000.0;
p_pu * impedance_pu * 100.0
}
}
#[derive(Debug, Clone)]
pub struct EvChargingSession {
pub ev_id: usize,
pub arrival_hour: usize,
pub departure_hour: usize,
pub energy_needed_kwh: f64,
pub charger_kw: f64,
pub initial_soc: f64,
pub battery_kwh: f64,
pub scheduled_power_kw: Vec<f64>,
}
impl EvChargingSession {
pub fn new(
ev_id: usize,
arrival_hour: usize,
departure_hour: usize,
energy_needed_kwh: f64,
charger_kw: f64,
initial_soc: f64,
battery_kwh: f64,
) -> Self {
Self {
ev_id,
arrival_hour,
departure_hour,
energy_needed_kwh,
charger_kw,
initial_soc,
battery_kwh,
scheduled_power_kw: vec![0.0; 24],
}
}
pub fn total_scheduled_kwh(&self) -> f64 {
self.scheduled_power_kw.iter().sum()
}
pub fn is_satisfied(&self) -> bool {
self.total_scheduled_kwh() >= self.energy_needed_kwh - 1e-6
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum CoordinationMode {
Uncontrolled,
TouOptimal,
CarbonMinimal,
GridFriendly,
}
#[derive(Debug, Clone)]
pub struct CoordinationResult {
pub mode: CoordinationMode,
pub total_cost: f64,
pub total_carbon_kg: f64,
pub peak_mw: f64,
pub sessions_satisfied_pct: f64,
}
pub struct SmartChargingCoordinator {
pub sessions: Vec<EvChargingSession>,
pub grid_capacity_mw: f64,
pub tou_prices: Vec<f64>,
pub carbon_intensity: Vec<f64>,
pub mode: CoordinationMode,
}
impl SmartChargingCoordinator {
pub fn new(capacity_mw: f64, prices: Vec<f64>, carbon: Vec<f64>) -> Self {
Self {
sessions: Vec::new(),
grid_capacity_mw: capacity_mw,
tou_prices: prices,
carbon_intensity: carbon,
mode: CoordinationMode::GridFriendly,
}
}
pub fn add_session(&mut self, session: EvChargingSession) {
self.sessions.push(session);
}
pub fn coordinate(&mut self) -> CoordinationResult {
let hours = 24_usize;
for s in &mut self.sessions {
s.scheduled_power_kw = vec![0.0; hours];
}
let tou_prices = self.tou_prices.clone();
let carbon_intensity = self.carbon_intensity.clone();
let mode = self.mode.clone();
match mode {
CoordinationMode::Uncontrolled => self.schedule_uncontrolled(),
CoordinationMode::TouOptimal => self.schedule_sorted(|a, b, _, _| {
let pa = tou_prices.get(a).copied().unwrap_or(f64::MAX);
let pb = tou_prices.get(b).copied().unwrap_or(f64::MAX);
pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
}),
CoordinationMode::CarbonMinimal => self.schedule_sorted(|a, b, _, _| {
let ca = carbon_intensity.get(a).copied().unwrap_or(f64::MAX);
let cb = carbon_intensity.get(b).copied().unwrap_or(f64::MAX);
ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
}),
CoordinationMode::GridFriendly => {
let base: Vec<f64> = (0..hours)
.map(|h| {
self.sessions
.iter()
.map(|s| s.scheduled_power_kw.get(h).copied().unwrap_or(0.0))
.sum::<f64>()
/ 1_000.0
})
.collect();
self.schedule_sorted(|a, b, _, _| {
let la = base.get(a).copied().unwrap_or(0.0);
let lb = base.get(b).copied().unwrap_or(0.0);
la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
})
}
};
self.build_result()
}
fn schedule_uncontrolled(&mut self) {
let hours = 24_usize;
for s in &mut self.sessions {
let mut remaining_kwh = s.energy_needed_kwh;
let dep = s.departure_hour.min(hours);
for h in s.arrival_hour..dep {
if remaining_kwh <= 0.0 {
break;
}
let power = s.charger_kw.min(remaining_kwh);
s.scheduled_power_kw[h] = power;
remaining_kwh -= power;
}
}
}
fn schedule_sorted<F>(&mut self, cmp: F)
where
F: Fn(usize, usize, usize, usize) -> std::cmp::Ordering,
{
let hours = 24_usize;
for (idx, s) in self.sessions.iter_mut().enumerate() {
let dep = s.departure_hour.min(hours);
let window: Vec<usize> = (s.arrival_hour..dep).collect();
let mut sorted_window = window.clone();
sorted_window.sort_by(|&a, &b| cmp(a, b, idx, hours));
let mut remaining_kwh = s.energy_needed_kwh;
for h in sorted_window {
if remaining_kwh <= 0.0 {
break;
}
let power = s.charger_kw.min(remaining_kwh);
s.scheduled_power_kw[h] = power;
remaining_kwh -= power;
}
}
}
pub fn total_demand_profile(&self) -> Vec<f64> {
let hours = 24_usize;
let mut profile = vec![0.0_f64; hours];
for s in &self.sessions {
for (h, slot) in profile.iter_mut().enumerate().take(hours) {
*slot += s.scheduled_power_kw.get(h).copied().unwrap_or(0.0) / 1_000.0;
}
}
profile
}
pub fn cost_comparison(&self) -> (f64, f64, f64, f64) {
let modes = [
CoordinationMode::Uncontrolled,
CoordinationMode::TouOptimal,
CoordinationMode::CarbonMinimal,
CoordinationMode::GridFriendly,
];
let mut costs = [0.0_f64; 4];
for (i, mode) in modes.iter().enumerate() {
let mut clone = SmartChargingCoordinator {
sessions: self.sessions.clone(),
grid_capacity_mw: self.grid_capacity_mw,
tou_prices: self.tou_prices.clone(),
carbon_intensity: self.carbon_intensity.clone(),
mode: mode.clone(),
};
let result = clone.coordinate();
costs[i] = result.total_cost;
}
(costs[0], costs[1], costs[2], costs[3])
}
fn build_result(&self) -> CoordinationResult {
let hours = 24_usize;
let profile = self.total_demand_profile();
let peak_mw = profile.iter().cloned().fold(0.0_f64, f64::max);
let mut total_cost = 0.0_f64;
let mut total_carbon_kg = 0.0_f64;
for s in &self.sessions {
for h in 0..hours {
let p_kw = s.scheduled_power_kw.get(h).copied().unwrap_or(0.0);
let p_mwh = p_kw / 1_000.0; let price = self.tou_prices.get(h).copied().unwrap_or(0.0);
let carbon = self.carbon_intensity.get(h).copied().unwrap_or(0.0);
total_cost += p_mwh * price;
total_carbon_kg += p_kw * carbon / 1_000.0; }
}
let satisfied = self.sessions.iter().filter(|s| s.is_satisfied()).count();
let sessions_satisfied_pct = if self.sessions.is_empty() {
100.0
} else {
satisfied as f64 / self.sessions.len() as f64 * 100.0
};
CoordinationResult {
mode: self.mode.clone(),
total_cost,
total_carbon_kg,
peak_mw,
sessions_satisfied_pct,
}
}
}
#[derive(Debug, Clone)]
pub struct V2gRevenueCalculator {
pub fleet_vehicles: usize,
pub battery_kwh_per_vehicle: f64,
pub usable_soc_range: (f64, f64),
pub charger_kw: f64,
pub round_trip_efficiency: f64,
pub availability_hours_per_day: f64,
pub degradation_cost_per_kwh: f64,
}
impl V2gRevenueCalculator {
pub fn fleet_v2g_capacity_mw(&self) -> f64 {
self.fleet_vehicles as f64 * self.charger_kw / 1_000.0
}
pub fn fleet_energy_available_kwh(&self) -> f64 {
let (soc_min, soc_max) = self.usable_soc_range;
let eta_discharge = self.round_trip_efficiency.max(0.0).sqrt();
self.fleet_vehicles as f64
* self.battery_kwh_per_vehicle
* (soc_max - soc_min).max(0.0)
* eta_discharge
}
pub fn frequency_regulation_revenue_per_day(&self, fr_price_per_mw_h: f64) -> f64 {
let revenue =
self.fleet_v2g_capacity_mw() * self.availability_hours_per_day * fr_price_per_mw_h;
let energy_cycled =
self.fleet_energy_available_kwh() * self.availability_hours_per_day / 8.0;
let degradation = self.degradation_cost_per_kwh * energy_cycled;
revenue - degradation
}
pub fn arbitrage_revenue_per_day(
&self,
buy_price_per_mwh: f64,
sell_price_per_mwh: f64,
cycles: f64,
) -> f64 {
let energy_kwh = self.fleet_energy_available_kwh();
let gross = cycles * energy_kwh * (sell_price_per_mwh - buy_price_per_mwh) / 1_000.0
* self.round_trip_efficiency.max(0.0).sqrt();
let degradation = cycles * energy_kwh * self.degradation_cost_per_kwh;
gross - degradation
}
pub fn net_annual_value(
&self,
fr_price: f64,
peak_arbitrage_spread: f64,
utilization_days: f64,
) -> f64 {
let daily_fr = self.frequency_regulation_revenue_per_day(fr_price);
let daily_arb = self.arbitrage_revenue_per_day(0.0, peak_arbitrage_spread, 1.0);
(daily_fr + daily_arb) * utilization_days
}
pub fn break_even_battery_cycles(&self, installation_cost: f64) -> f64 {
let energy = self.fleet_energy_available_kwh();
if energy <= 0.0 || self.degradation_cost_per_kwh <= 0.0 {
return f64::INFINITY;
}
installation_cost / (self.degradation_cost_per_kwh * energy)
}
}
#[derive(Debug, Clone)]
pub struct DrActivationResult {
pub activated_mw: f64,
pub shortfall_mw: f64,
pub participating_evs: usize,
pub estimated_cost: f64,
}
#[derive(Debug, Clone)]
pub struct EvDemandResponse {
pub enrolled_evs: usize,
pub response_capacity_fraction: f64,
pub min_soc_for_dr: f64,
pub notification_delay_min: f64,
pub max_curtailment_pct: f64,
pub compensation_per_kwh: f64,
}
impl EvDemandResponse {
pub fn available_curtailment_mw(&self, current_demand_mw: f64, avg_soc: f64) -> f64 {
if avg_soc < self.min_soc_for_dr {
return 0.0;
}
current_demand_mw * self.response_capacity_fraction * self.max_curtailment_pct / 100.0
}
pub fn activate_event(
&self,
requested_mw: f64,
current_soc: f64,
duration_h: f64,
) -> DrActivationResult {
if current_soc < self.min_soc_for_dr {
return DrActivationResult {
activated_mw: 0.0,
shortfall_mw: requested_mw,
participating_evs: 0,
estimated_cost: 0.0,
};
}
let max_curtail =
requested_mw * self.response_capacity_fraction * self.max_curtailment_pct / 100.0;
let activated_mw = max_curtail.min(requested_mw);
let shortfall_mw = (requested_mw - activated_mw).max(0.0);
let participating_evs =
(self.enrolled_evs as f64 * self.response_capacity_fraction).round() as usize;
let curtailed_kwh = activated_mw * 1_000.0 * duration_h;
let estimated_cost = curtailed_kwh * self.compensation_per_kwh;
DrActivationResult {
activated_mw,
shortfall_mw,
participating_evs,
estimated_cost,
}
}
pub fn annual_dr_value(
&self,
events_per_year: usize,
avg_event_mw: f64,
avg_duration_h: f64,
) -> f64 {
let curtailed_kwh_per_event = avg_event_mw * 1_000.0 * avg_duration_h;
let cost_per_event = curtailed_kwh_per_event * self.compensation_per_kwh;
let avoided_cost_per_event = avg_event_mw * 100.0 * avg_duration_h;
(avoided_cost_per_event - cost_per_event) * events_per_year as f64
}
}
#[allow(non_camel_case_types)]
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkChargerType {
Level1_1kw,
Level2_7kw,
Dcfc_50kw,
Dcfc_150kw,
Hpc_350kw,
}
#[derive(Debug, Clone)]
pub struct ChargingLocation {
pub id: usize,
pub x: f64,
pub y: f64,
pub charger_type: NetworkChargerType,
pub install_cost: f64,
pub annual_opex: f64,
pub charger_kw: f64,
pub capacity: usize,
}
pub struct ChargingNetworkOptimizer {
pub candidate_locations: Vec<ChargingLocation>,
pub budget: f64,
pub ev_demand_nodes: Vec<(f64, f64, f64)>,
}
impl ChargingNetworkOptimizer {
pub fn coverage_radius_km(&self, charger_type: &NetworkChargerType) -> f64 {
match charger_type {
NetworkChargerType::Level1_1kw => 1.0,
NetworkChargerType::Level2_7kw => 5.0,
NetworkChargerType::Dcfc_50kw => 20.0,
NetworkChargerType::Dcfc_150kw => 50.0,
NetworkChargerType::Hpc_350kw => 100.0,
}
}
fn demand_served(&self, loc: &ChargingLocation) -> f64 {
let radius = self.coverage_radius_km(&loc.charger_type);
self.ev_demand_nodes
.iter()
.filter(|(nx, ny, _)| {
let dx = nx - loc.x;
let dy = ny - loc.y;
(dx * dx + dy * dy).sqrt() <= radius
})
.map(|(_, _, d)| *d)
.sum()
}
pub fn greedy_placement(&self) -> Vec<usize> {
let mut scored: Vec<(usize, f64)> = self
.candidate_locations
.iter()
.filter(|l| l.install_cost > 0.0)
.map(|l| {
let score = self.demand_served(l) / l.install_cost;
(l.id, score)
})
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut selected = Vec::new();
let mut spent = 0.0_f64;
for (id, _) in &scored {
if let Some(loc) = self.candidate_locations.iter().find(|l| l.id == *id) {
if spent + loc.install_cost <= self.budget {
selected.push(*id);
spent += loc.install_cost;
}
}
}
selected
}
pub fn total_capacity_selected(&self, selected: &[usize]) -> f64 {
self.candidate_locations
.iter()
.filter(|l| selected.contains(&l.id))
.map(|l| l.charger_kw)
.sum()
}
pub fn population_coverage_pct(&self, selected: &[usize]) -> f64 {
if self.ev_demand_nodes.is_empty() {
return 0.0;
}
let selected_locs: Vec<&ChargingLocation> = self
.candidate_locations
.iter()
.filter(|l| selected.contains(&l.id))
.collect();
let covered = self
.ev_demand_nodes
.iter()
.filter(|(nx, ny, _)| {
selected_locs.iter().any(|l| {
let dx = nx - l.x;
let dy = ny - l.y;
let dist = (dx * dx + dy * dy).sqrt();
dist <= self.coverage_radius_km(&l.charger_type)
})
})
.count();
covered as f64 / self.ev_demand_nodes.len() as f64
}
}
#[derive(Debug, Clone)]
pub struct GridEvSynergy {
pub renewable_gen_mwh: Vec<f64>,
pub ev_load_mwh: Vec<f64>,
pub conventional_load_mwh: Vec<f64>,
}
impl GridEvSynergy {
fn len(&self) -> usize {
self.renewable_gen_mwh
.len()
.min(self.ev_load_mwh.len())
.min(self.conventional_load_mwh.len())
}
pub fn renewable_utilization_pct(&self) -> f64 {
let n = self.len();
if n == 0 {
return 0.0;
}
let absorbed: f64 = (0..n)
.map(|i| self.ev_load_mwh[i].min(self.renewable_gen_mwh[i]).max(0.0))
.sum();
let total_re: f64 = self.renewable_gen_mwh[..n].iter().sum();
if total_re <= 0.0 {
return 0.0;
}
(absorbed / total_re).clamp(0.0, 1.0)
}
pub fn ev_renewable_match_pct(&self) -> f64 {
let n = self.len();
if n == 0 {
return 0.0;
}
let matched: f64 = (0..n)
.map(|i| self.ev_load_mwh[i].min(self.renewable_gen_mwh[i]).max(0.0))
.sum();
let total_ev: f64 = self.ev_load_mwh[..n].iter().sum();
if total_ev <= 0.0 {
return 0.0;
}
(matched / total_ev).clamp(0.0, 1.0)
}
pub fn peak_shaving_mw(&self) -> f64 {
let n = self.len();
if n == 0 {
return 0.0;
}
let total: Vec<f64> = (0..n)
.map(|i| self.conventional_load_mwh[i] + self.ev_load_mwh[i])
.collect();
let peak = total.iter().cloned().fold(0.0_f64, f64::max);
let mean = total.iter().sum::<f64>() / n as f64;
(peak - mean).max(0.0)
}
pub fn valley_filling_score(&self) -> f64 {
let n = self.len();
if n == 0 {
return 0.0;
}
let std_dev = |v: &[f64]| -> f64 {
let mean = v.iter().sum::<f64>() / v.len() as f64;
let var = v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / v.len() as f64;
var.sqrt()
};
let total: Vec<f64> = (0..n)
.map(|i| self.conventional_load_mwh[i] + self.ev_load_mwh[i])
.collect();
let std_conv = std_dev(&self.conventional_load_mwh[..n]);
let std_total = std_dev(&total);
if std_conv <= 0.0 {
return 0.0;
}
(1.0 - std_total / std_conv).clamp(0.0, 1.0)
}
pub fn self_sufficiency_pct(&self) -> f64 {
self.ev_renewable_match_pct()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fleet_profile() -> EvFleetProfile {
EvFleetProfile {
total_evs: 1000,
avg_battery_kwh: 60.0,
avg_daily_km: 40.0,
energy_per_km_kwh: 0.2,
home_charging_fraction: 0.7,
workplace_charging_fraction: 0.2,
public_charging_fraction: 0.1,
adoption_rate_pct: 50.0,
}
}
fn make_impact() -> EvGridImpact {
EvGridImpact {
feeder_id: 1,
transformer_kva: 1000.0, feeder_peak_mw: 0.5,
base_load_profile_mw: vec![0.3; 24],
ev_fleet: make_fleet_profile(),
}
}
#[test]
fn test_daily_energy_demand() {
let impact = make_impact();
let expected = 1000.0 * 40.0 * 0.2 * 50.0 / 100.0;
let got = impact.daily_energy_demand_kwh();
assert!(
(got - expected).abs() < 1e-6,
"got={got}, expected={expected}"
);
}
#[test]
fn test_hosting_capacity_returns_usize() {
let impact = make_impact();
let cap: usize = impact.hosting_capacity_evs(90.0);
assert!(cap < usize::MAX, "Capacity should be finite");
}
#[test]
fn test_transformer_loading_pct() {
let impact = make_impact();
let loading = impact.transformer_loading_pct(0.5);
assert!((loading - 50.0).abs() < 1e-9, "loading={loading}");
}
#[test]
fn test_uncontrolled_has_charge_at_arrival() {
let impact = make_impact();
let profile = impact.uncontrolled_charging_profile();
assert_eq!(profile.len(), 24);
assert!(
profile[18] > 0.0,
"Hour 18 should have positive EV load, got {}",
profile[18]
);
}
#[test]
fn test_hosting_capacity_positive_for_reasonable_input() {
let impact = make_impact();
let cap = impact.hosting_capacity_evs(90.0);
assert!(cap > 0, "Expected positive hosting capacity, got {cap}");
}
fn make_coordinator(mode: CoordinationMode) -> SmartChargingCoordinator {
let prices: Vec<f64> = (0..24)
.map(|h| if (9..21).contains(&h) { 0.25 } else { 0.05 })
.collect();
let carbon: Vec<f64> = (0..24)
.map(|h| if (12..18).contains(&h) { 50.0 } else { 200.0 })
.collect();
let mut coord = SmartChargingCoordinator::new(10.0, prices, carbon);
coord.mode = mode;
coord
}
fn add_test_session(coord: &mut SmartChargingCoordinator) {
let session = EvChargingSession::new(0, 18, 23, 10.0, 7.0, 0.3, 60.0);
coord.add_session(session);
}
#[test]
fn test_uncontrolled_has_charge_at_arrival_hour() {
let mut coord = make_coordinator(CoordinationMode::Uncontrolled);
add_test_session(&mut coord);
coord.coordinate();
let power_at_18 = coord.sessions[0].scheduled_power_kw[18];
assert!(
power_at_18 > 0.0,
"Uncontrolled should charge at arrival hour 18, got {power_at_18}"
);
}
#[test]
fn test_tou_charges_in_cheapest_hours() {
let prices: Vec<f64> = (0..24)
.map(|h| if (9..21).contains(&h) { 0.25 } else { 0.05 })
.collect();
let carbon = vec![100.0; 24];
let mut coord = SmartChargingCoordinator::new(10.0, prices, carbon);
coord.mode = CoordinationMode::TouOptimal;
let session = EvChargingSession::new(0, 8, 23, 10.0, 7.0, 0.3, 60.0);
coord.add_session(session);
coord.coordinate();
let sched = &coord.sessions[0].scheduled_power_kw;
let cheap_hour = sched[8];
let expensive_hour = sched[9];
assert!(
cheap_hour >= expensive_hour,
"TOU should prefer cheap hours: h8={cheap_hour}, h9={expensive_hour}"
);
}
#[test]
fn test_total_demand_profile_sums_sessions() {
let mut coord = make_coordinator(CoordinationMode::Uncontrolled);
add_test_session(&mut coord);
coord.add_session(EvChargingSession::new(1, 19, 23, 5.0, 7.0, 0.5, 40.0));
coord.coordinate();
let profile = coord.total_demand_profile();
let profile_total_kwh: f64 = profile.iter().sum::<f64>() * 1_000.0; let sessions_total_kwh: f64 = coord.sessions.iter().map(|s| s.total_scheduled_kwh()).sum();
assert!(
(profile_total_kwh - sessions_total_kwh).abs() < 1e-6,
"profile sum={profile_total_kwh}, sessions sum={sessions_total_kwh}"
);
}
fn make_v2g() -> V2gRevenueCalculator {
V2gRevenueCalculator {
fleet_vehicles: 100,
battery_kwh_per_vehicle: 60.0,
usable_soc_range: (0.2, 0.8),
charger_kw: 11.0,
round_trip_efficiency: 0.9,
availability_hours_per_day: 8.0,
degradation_cost_per_kwh: 0.05,
}
}
#[test]
fn test_v2g_fleet_capacity_formula() {
let v2g = make_v2g();
let expected = 100.0 * 11.0 / 1_000.0;
let got = v2g.fleet_v2g_capacity_mw();
assert!(
(got - expected).abs() < 1e-9,
"got={got}, expected={expected}"
);
}
#[test]
fn test_v2g_net_annual_value_positive() {
let v2g = make_v2g();
let val = v2g.net_annual_value(100.0, 80.0, 250.0);
assert!(val > 0.0, "Net annual value should be positive, got {val}");
}
fn make_dr() -> EvDemandResponse {
EvDemandResponse {
enrolled_evs: 500,
response_capacity_fraction: 0.8,
min_soc_for_dr: 0.3,
notification_delay_min: 15.0,
max_curtailment_pct: 50.0,
compensation_per_kwh: 0.10,
}
}
#[test]
fn test_dr_curtailment_lte_demand() {
let dr = make_dr();
let current_demand_mw = 5.0;
let curtail = dr.available_curtailment_mw(current_demand_mw, 0.6);
assert!(
curtail <= current_demand_mw,
"Curtailment {curtail} exceeds demand {current_demand_mw}"
);
}
#[test]
fn test_dr_shortfall_when_low_soc() {
let dr = make_dr();
let result = dr.activate_event(2.0, 0.1, 1.0);
assert!(
result.shortfall_mw > 0.0,
"Expected shortfall when SoC too low, got shortfall={}",
result.shortfall_mw
);
assert_eq!(result.activated_mw, 0.0);
}
fn make_optimizer() -> ChargingNetworkOptimizer {
let locations = vec![
ChargingLocation {
id: 0,
x: 0.0,
y: 0.0,
charger_type: NetworkChargerType::Level2_7kw,
install_cost: 5_000.0,
annual_opex: 500.0,
charger_kw: 7.0,
capacity: 30,
},
ChargingLocation {
id: 1,
x: 3.0,
y: 0.0,
charger_type: NetworkChargerType::Dcfc_50kw,
install_cost: 30_000.0,
annual_opex: 3_000.0,
charger_kw: 50.0,
capacity: 100,
},
ChargingLocation {
id: 2,
x: 1.0,
y: 1.0,
charger_type: NetworkChargerType::Level1_1kw,
install_cost: 1_000.0,
annual_opex: 100.0,
charger_kw: 1.0,
capacity: 10,
},
];
let demand_nodes = vec![(0.5, 0.5, 500.0), (2.5, 0.0, 300.0), (0.0, 2.0, 200.0)];
ChargingNetworkOptimizer {
candidate_locations: locations,
budget: 10_000.0,
ev_demand_nodes: demand_nodes,
}
}
#[test]
fn test_greedy_placement_within_budget() {
let opt = make_optimizer();
let selected = opt.greedy_placement();
let total_cost: f64 = opt
.candidate_locations
.iter()
.filter(|l| selected.contains(&l.id))
.map(|l| l.install_cost)
.sum();
assert!(
total_cost <= opt.budget + 1e-6,
"Selected cost {total_cost} exceeds budget {}",
opt.budget
);
}
#[test]
fn test_coverage_radius_positive_for_all_types() {
let opt = make_optimizer();
for ct in [
NetworkChargerType::Level1_1kw,
NetworkChargerType::Level2_7kw,
NetworkChargerType::Dcfc_50kw,
NetworkChargerType::Dcfc_150kw,
NetworkChargerType::Hpc_350kw,
] {
let r = opt.coverage_radius_km(&ct);
assert!(r > 0.0, "Coverage radius for {ct:?} should be > 0, got {r}");
}
}
fn make_synergy() -> GridEvSynergy {
let renewable: Vec<f64> = (0..24)
.map(|h| if (10..16).contains(&h) { 5.0 } else { 0.5 })
.collect();
let ev_load: Vec<f64> = (0..24)
.map(|h| if (18..22).contains(&h) { 2.0 } else { 0.1 })
.collect();
let conv_load: Vec<f64> = (0..24)
.map(|h| if (8..20).contains(&h) { 8.0 } else { 4.0 })
.collect();
GridEvSynergy {
renewable_gen_mwh: renewable,
ev_load_mwh: ev_load,
conventional_load_mwh: conv_load,
}
}
#[test]
fn test_renewable_utilization_between_0_and_1() {
let synergy = make_synergy();
let util = synergy.renewable_utilization_pct();
assert!(
(0.0..=1.0).contains(&util),
"Renewable utilization {util} not in [0,1]"
);
}
#[test]
fn test_valley_filling_score_non_negative() {
let conv_load = vec![4.0, 4.0, 4.0, 8.0, 8.0, 8.0, 4.0, 4.0];
let synergy = GridEvSynergy {
renewable_gen_mwh: vec![1.0; 8],
ev_load_mwh: vec![2.0; 8], conventional_load_mwh: conv_load,
};
let score = synergy.valley_filling_score();
assert!(
score >= 0.0,
"Valley filling score should be ≥ 0, got {score}"
);
}
#[test]
fn test_valley_filling_score_positive_for_good_charging() {
let conv_load: Vec<f64> = vec![2.0, 2.0, 8.0, 8.0, 8.0, 2.0, 2.0, 2.0];
let ev_load: Vec<f64> = vec![3.0, 3.0, 0.0, 0.0, 0.0, 3.0, 3.0, 3.0];
let synergy = GridEvSynergy {
renewable_gen_mwh: vec![1.0; 8],
ev_load_mwh: ev_load,
conventional_load_mwh: conv_load,
};
let score = synergy.valley_filling_score();
assert!(
score > 0.0,
"Valley filling score should be > 0 for good charging, got {score}"
);
}
#[test]
fn test_ev_renewable_match_pct_in_range() {
let synergy = make_synergy();
let pct = synergy.ev_renewable_match_pct();
assert!(
(0.0..=1.0).contains(&pct),
"EV renewable match {pct} not in [0,1]"
);
}
#[test]
fn test_peak_demand_increase_mw_positive() {
let impact = make_impact();
let peak = impact.peak_demand_increase_mw();
assert!(
peak > 0.0,
"peak_demand_increase_mw should be positive, got {peak}"
);
}
#[test]
fn test_voltage_impact_estimate_pct_proportional_to_impedance() {
let impact = make_impact();
let v1 = impact.voltage_impact_estimate_pct(0.01);
let v2 = impact.voltage_impact_estimate_pct(0.02);
assert!(
(v2 - 2.0 * v1).abs() < 1e-9,
"voltage impact should scale linearly with impedance: v1={v1}, v2={v2}"
);
}
#[test]
fn test_charging_session_is_satisfied_after_full_schedule() {
let mut session = EvChargingSession::new(0, 18, 23, 10.0, 7.0, 0.2, 60.0);
session.scheduled_power_kw[18] = 5.0;
session.scheduled_power_kw[19] = 5.0;
assert!(
(session.total_scheduled_kwh() - 10.0).abs() < 1e-9,
"total should be 10.0, got {}",
session.total_scheduled_kwh()
);
assert!(session.is_satisfied(), "session should be satisfied");
}
#[test]
fn test_cost_comparison_tou_lte_uncontrolled_cost() {
let coord = make_coordinator(CoordinationMode::Uncontrolled);
let (uncontrolled, tou, _carbon, _grid) = coord.cost_comparison();
assert!(
tou <= uncontrolled + 1e-6,
"TOU-optimal cost ({tou:.4}) should not exceed uncontrolled cost ({uncontrolled:.4})"
);
}
#[test]
fn test_fleet_energy_available_kwh_scales_with_fleet_size() {
let make_calc = |n: usize| V2gRevenueCalculator {
fleet_vehicles: n,
battery_kwh_per_vehicle: 60.0,
usable_soc_range: (0.2, 0.8),
charger_kw: 11.0,
round_trip_efficiency: 0.9,
availability_hours_per_day: 8.0,
degradation_cost_per_kwh: 0.05,
};
let e100 = make_calc(100).fleet_energy_available_kwh();
let e200 = make_calc(200).fleet_energy_available_kwh();
assert!(
(e200 - 2.0 * e100).abs() < 1e-6,
"doubling fleet should double energy: e100={e100}, e200={e200}"
);
}
#[test]
fn test_arbitrage_revenue_per_day_positive_spread() {
let calc = V2gRevenueCalculator {
fleet_vehicles: 100,
battery_kwh_per_vehicle: 60.0,
usable_soc_range: (0.2, 0.8),
charger_kw: 11.0,
round_trip_efficiency: 0.9,
availability_hours_per_day: 8.0,
degradation_cost_per_kwh: 0.01, };
let revenue = calc.arbitrage_revenue_per_day(50.0, 200.0, 1.0);
assert!(
revenue > 0.0,
"arbitrage revenue should be positive with a large spread, got {revenue}"
);
}
#[test]
fn test_available_curtailment_mw_zero_below_min_soc() {
let dr = EvDemandResponse {
enrolled_evs: 500,
response_capacity_fraction: 0.8,
min_soc_for_dr: 0.3,
notification_delay_min: 5.0,
max_curtailment_pct: 50.0,
compensation_per_kwh: 0.10,
};
let curtail = dr.available_curtailment_mw(10.0, 0.1);
assert!(
curtail.abs() < 1e-9,
"curtailment should be zero below min SoC, got {curtail}"
);
let curtail_ok = dr.available_curtailment_mw(10.0, 0.5);
assert!(
curtail_ok > 0.0,
"curtailment should be positive above min SoC, got {curtail_ok}"
);
}
#[test]
fn test_population_coverage_pct_full_when_in_radius() {
let optimizer = ChargingNetworkOptimizer {
candidate_locations: vec![ChargingLocation {
id: 1,
x: 0.0,
y: 0.0,
charger_type: NetworkChargerType::Dcfc_150kw,
install_cost: 10_000.0,
annual_opex: 1_000.0,
charger_kw: 150.0,
capacity: 20,
}],
budget: 50_000.0,
ev_demand_nodes: vec![(1.0, 1.0, 100.0), (2.0, 2.0, 80.0), (3.0, 3.0, 60.0)],
};
let coverage = optimizer.population_coverage_pct(&[1]);
assert!(
(coverage - 1.0).abs() < 1e-9,
"all demand nodes should be covered, got {coverage}"
);
}
}