vpp_sim/devices/
ev_charger.rs

1use crate::devices::types::{Device, DeviceContext};
2use rand::{RngExt, SeedableRng, rngs::StdRng};
3
4#[derive(Debug, Clone)]
5struct EvSession {
6    arrival_step: usize,
7    deadline_step: usize,
8    remaining_kwh: f32,
9}
10
11/// A flexible electric load model using EV-style charging sessions.
12///
13/// Each simulated day, this model samples one charging session with:
14/// - random arrival time
15/// - random dwell duration (which sets deadline)
16/// - random required energy in kWh
17///
18/// During an active session, charging power is computed as the minimum required
19/// to meet the remaining energy by the deadline, limited by `max_charge_kw`.
20#[derive(Debug)]
21pub struct EvCharger {
22    /// Maximum charging power in kilowatts.
23    pub max_charge_kw: f32,
24
25    /// Number of simulation steps per day.
26    pub steps_per_day: usize,
27
28    /// Minimum daily charging demand in kWh.
29    pub demand_kwh_min: f32,
30
31    /// Maximum daily charging demand in kWh.
32    pub demand_kwh_max: f32,
33
34    /// Minimum connected duration in simulation steps.
35    pub dwell_steps_min: usize,
36
37    /// Maximum connected duration in simulation steps.
38    pub dwell_steps_max: usize,
39
40    sampled_day: Option<usize>,
41    session: Option<EvSession>,
42    rng: StdRng,
43}
44
45impl EvCharger {
46    pub fn new(
47        max_charge_kw: f32,
48        steps_per_day: usize,
49        demand_kwh_min: f32,
50        demand_kwh_max: f32,
51        dwell_steps_min: usize,
52        dwell_steps_max: usize,
53        seed: u64,
54    ) -> Self {
55        assert!(max_charge_kw > 0.0);
56        assert!(steps_per_day > 0);
57        assert!(demand_kwh_min >= 0.0);
58        assert!(demand_kwh_max >= demand_kwh_min);
59        assert!(dwell_steps_min > 0);
60        assert!(dwell_steps_max >= dwell_steps_min);
61
62        Self {
63            max_charge_kw,
64            steps_per_day,
65            demand_kwh_min,
66            demand_kwh_max,
67            dwell_steps_min,
68            dwell_steps_max,
69            sampled_day: None,
70            session: None,
71            rng: StdRng::seed_from_u64(seed),
72        }
73    }
74
75    fn dt_hours(&self) -> f32 {
76        24.0 / self.steps_per_day as f32
77    }
78
79    fn sample_session_for_day(&mut self, day: usize) {
80        let dwell_max = self.dwell_steps_max.min(self.steps_per_day);
81        let dwell_min = self.dwell_steps_min.min(dwell_max);
82        let dwell = self.rng.random_range(dwell_min..=dwell_max);
83
84        let latest_arrival = self.steps_per_day - dwell;
85        let arrival = self.rng.random_range(0..=latest_arrival);
86        let deadline = arrival + dwell;
87
88        let max_deliverable_kwh = self.max_charge_kw * self.dt_hours() * dwell as f32;
89        let raw_demand = self
90            .rng
91            .random_range(self.demand_kwh_min..=self.demand_kwh_max);
92        let demand_kwh = raw_demand.min(max_deliverable_kwh).max(0.0);
93
94        self.sampled_day = Some(day);
95        self.session = Some(EvSession {
96            arrival_step: arrival,
97            deadline_step: deadline,
98            remaining_kwh: demand_kwh,
99        });
100    }
101
102    /// Returns the unconstrained charging request at the current timestep.
103    pub fn requested_power_kw(&mut self, context: &DeviceContext) -> f32 {
104        let day = context.timestep / self.steps_per_day;
105        let day_t = context.timestep % self.steps_per_day;
106        let dt_hours = self.dt_hours();
107
108        if self.sampled_day != Some(day) {
109            self.sample_session_for_day(day);
110        }
111
112        let Some(session) = &self.session else {
113            return 0.0;
114        };
115
116        if day_t < session.arrival_step || day_t >= session.deadline_step {
117            return 0.0;
118        }
119
120        if session.remaining_kwh <= 0.0 {
121            return 0.0;
122        }
123
124        let remaining_steps = session.deadline_step - day_t;
125        if remaining_steps == 0 {
126            return 0.0;
127        }
128
129        (session.remaining_kwh / (remaining_steps as f32 * dt_hours)).max(0.0)
130    }
131}
132
133impl Device for EvCharger {
134    fn power_kw(&mut self, context: &DeviceContext) -> f32 {
135        let requested_kw = self.requested_power_kw(context);
136        let dt_hours = self.dt_hours();
137
138        if requested_kw <= 0.0 {
139            return 0.0;
140        }
141
142        let cap_kw = context.setpoint_kw.unwrap_or(self.max_charge_kw).max(0.0);
143        let charge_kw = requested_kw.min(cap_kw).min(self.max_charge_kw).max(0.0);
144
145        let Some(session) = &mut self.session else {
146            return 0.0;
147        };
148
149        let delivered_kwh = charge_kw * dt_hours;
150        session.remaining_kwh = (session.remaining_kwh - delivered_kwh).max(0.0);
151
152        charge_kw
153    }
154
155    fn device_type(&self) -> &'static str {
156        "EvCharger"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn ctx(t: usize) -> DeviceContext {
165        DeviceContext::new(t)
166    }
167
168    #[test]
169    fn deterministic_for_same_seed() {
170        let mut ev1 = EvCharger::new(7.2, 24, 6.0, 12.0, 4, 10, 42);
171        let mut ev2 = EvCharger::new(7.2, 24, 6.0, 12.0, 4, 10, 42);
172
173        for t in 0..48 {
174            assert_eq!(ev1.power_kw(&ctx(t)), ev2.power_kw(&ctx(t)));
175        }
176    }
177
178    #[test]
179    fn no_charging_outside_session_window() {
180        let mut ev = EvCharger::new(7.2, 24, 0.0, 0.0, 4, 4, 7);
181
182        let mut non_zero_steps = 0;
183        for t in 0..24 {
184            if ev.power_kw(&ctx(t)) > 0.0 {
185                non_zero_steps += 1;
186            }
187        }
188
189        assert_eq!(non_zero_steps, 0);
190    }
191
192    #[test]
193    fn feasible_session_finishes_by_deadline() {
194        let mut ev = EvCharger::new(7.2, 24, 10.0, 10.0, 6, 6, 99);
195
196        let mut total_kwh = 0.0;
197        let dt_hours = 24.0 / 24.0;
198        for t in 0..24 {
199            total_kwh += ev.power_kw(&ctx(t)) * dt_hours;
200        }
201
202        assert!((total_kwh - 10.0).abs() < 1e-4);
203    }
204}