vpp_sim/devices/
battery.rs

1use crate::devices::types::{Device, DeviceContext};
2
3/// A battery energy storage system that can charge and discharge electricity.
4///
5/// `Battery` models a battery with configurable capacity, charge/discharge rates,
6/// and efficiencies. It maintains its state of charge (SOC) and enforces operational
7/// constraints when given power setpoints.
8///
9/// # Power Flow Convention
10/// - Positive power: Discharging (supplying power to the grid)
11/// - Negative power: Charging (consuming power from the grid)
12///
13/// # Examples
14///
15/// Note: `vpp-sim` currently ships as a binary-first crate; this snippet is illustrative.
16/// ```ignore
17/// use vpp_sim::devices::battery::Battery;
18/// use vpp_sim::devices::types::{Device, DeviceContext};
19///
20/// // Create a 10kWh battery at 50% SOC with 5kW charge/discharge limits
21/// let mut battery = Battery::new(
22///     10.0,  // capacity_kwh
23///     0.5,   // state of charge (50%)
24///     5.0,   // max_charge_kw
25///     5.0,   // max_discharge_kw
26///     0.95,  // charging efficiency
27///     0.95,  // discharging efficiency
28///     96,    // steps_per_day (15-min intervals)
29/// );
30///
31/// // Command battery to discharge at 3kW
32/// let context = DeviceContext::with_setpoint(0, 3.0);
33/// let actual_kw = battery.power_kw(&context);
34///
35/// // Command battery to charge at 2kW
36/// let context = DeviceContext::with_setpoint(1, -2.0);
37/// let actual_kw = battery.power_kw(&context);
38/// ```
39#[derive(Debug, Clone)]
40pub struct Battery {
41    /// Battery capacity in kilowatt-hours
42    pub capacity_kwh: f32,
43
44    /// State of charge as a fraction (0.0 to 1.0)
45    pub soc: f32,
46
47    /// Maximum charge power in kilowatts (positive value)
48    pub max_charge_kw: f32,
49
50    /// Maximum discharge power in kilowatts (positive value)
51    pub max_discharge_kw: f32,
52
53    /// Charging efficiency (0..1.0)
54    pub eta_c: f32,
55
56    /// Discharging efficiency (0..1.0)
57    pub eta_d: f32,
58
59    /// Number of time steps per day
60    pub steps_per_day: usize,
61}
62
63impl Battery {
64    pub fn new(
65        capacity_kwh: f32,
66        soc: f32,
67        max_charge_kw: f32,
68        max_discharge_kw: f32,
69        eta_c: f32,
70        eta_d: f32,
71        steps_per_day: usize,
72    ) -> Self {
73        assert!(capacity_kwh > 0.0);
74        assert!((0.0..=1.0).contains(&soc));
75        assert!(max_charge_kw >= 0.0 && max_discharge_kw >= 0.0);
76        assert!(eta_c > 0.0 && eta_c <= 1.0);
77        assert!(eta_d > 0.0 && eta_d <= 1.0);
78        assert!(steps_per_day > 0);
79
80        Self {
81            capacity_kwh,
82            soc,
83            max_charge_kw,
84            max_discharge_kw,
85            eta_c,
86            eta_d,
87            steps_per_day,
88        }
89    }
90}
91
92impl Device for Battery {
93    /// Returns the actual power output given a power setpoint.
94    ///
95    /// Takes a setpoint in kW and returns the actual power after accounting for
96    /// battery constraints:
97    /// - Enforces charge/discharge power limits
98    /// - Prevents over-charging or over-discharging
99    /// - Updates the battery's state of charge (SOC)
100    ///
101    /// # Power Convention
102    /// - Positive: Battery is discharging (delivering power)
103    /// - Negative: Battery is charging (consuming power)
104    ///
105    /// # Arguments
106    ///
107    /// * `setpoint_kw` - The requested power setpoint in kW
108    ///
109    /// # Returns
110    ///
111    /// The actual power output in kW after applying constraints
112    fn power_kw(&mut self, context: &DeviceContext) -> f32 {
113        let setpoint_kw = context.setpoint_kw.unwrap_or(0.0);
114        let dt_hours = 24.0 / self.steps_per_day as f32;
115
116        // First enforce kW limits
117        let cmd_kw = if setpoint_kw >= 0.0 {
118            // Discharge (positive)
119            setpoint_kw.min(self.max_discharge_kw)
120        } else {
121            // Charge (negative)
122            setpoint_kw.max(-self.max_charge_kw)
123        };
124
125        // Enforce SOC limits
126        if cmd_kw > 0.0 {
127            // Discharge
128            let max_kwh_this_step = self.soc * self.capacity_kwh * self.eta_d;
129            let max_kw_soc = max_kwh_this_step / dt_hours;
130            let actual_kw = cmd_kw.min(max_kw_soc.max(0.0));
131
132            // Update SOC
133            self.soc -= (actual_kw * dt_hours) / (self.capacity_kwh * self.eta_d);
134            self.soc = self.soc.clamp(0.0, 1.0);
135
136            actual_kw
137        } else if cmd_kw < 0.0 {
138            // Charge - limit by available capacity
139            let cmd_abs = -cmd_kw;
140            let max_kwh_this_step = (1.0 - self.soc) * self.capacity_kwh / self.eta_c;
141            let max_kw_soc = max_kwh_this_step / dt_hours;
142            let actual_abs_kw = cmd_abs.min(max_kw_soc.max(0.0));
143            let actual_kw = -actual_abs_kw;
144
145            // Update SOC
146            self.soc += (actual_abs_kw * dt_hours * self.eta_c) / self.capacity_kwh;
147            self.soc = self.soc.clamp(0.0, 1.0);
148
149            actual_kw
150        } else {
151            0.0 // No action if setpoint is exactly zero
152        }
153    }
154
155    fn device_type(&self) -> &'static str {
156        "Battery"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_new_battery() {
166        let battery = Battery::new(10.0, 0.5, 5.0, 5.0, 0.95, 0.95, 96);
167        assert_eq!(battery.capacity_kwh, 10.0);
168        assert_eq!(battery.soc, 0.5);
169        assert_eq!(battery.max_charge_kw, 5.0);
170        assert_eq!(battery.max_discharge_kw, 5.0);
171        assert_eq!(battery.eta_c, 0.95);
172        assert_eq!(battery.eta_d, 0.95);
173        assert!(battery.steps_per_day == 96);
174    }
175
176    #[test]
177    #[should_panic]
178    fn test_invalid_capacity() {
179        Battery::new(0.0, 0.5, 5.0, 5.0, 0.95, 0.95, 96);
180    }
181
182    #[test]
183    #[should_panic]
184    fn test_invalid_soc_high() {
185        Battery::new(10.0, 1.1, 5.0, 5.0, 0.95, 0.95, 96);
186    }
187
188    #[test]
189    #[should_panic]
190    fn test_invalid_soc_negative() {
191        Battery::new(10.0, -0.1, 5.0, 5.0, 0.95, 0.95, 96);
192    }
193
194    #[test]
195    fn test_charge_power_limit() {
196        let mut battery = Battery::new(10.0, 0.5, 5.0, 5.0, 1.0, 1.0, 96);
197        let context = DeviceContext::with_setpoint(0, -10.0);
198        let actual_kw = battery.power_kw(&context);
199        assert_eq!(actual_kw, -5.0); // Should be limited to -5kW
200    }
201
202    #[test]
203    fn test_discharge_power_limit() {
204        let mut battery = Battery::new(10.0, 0.5, 5.0, 5.0, 1.0, 1.0, 96);
205        let context = DeviceContext::with_setpoint(0, 10.0);
206        let actual_kw = battery.power_kw(&context);
207        assert_eq!(actual_kw, 5.0); // Should be limited to 5kW
208    }
209
210    #[test]
211    fn test_discharge_soc_limit() {
212        // Battery at 10% SOC with 10kWh capacity (= 1kWh available)
213        // With 0.25h timestep and perfect efficiency, max discharge is 4kW
214        let mut battery = Battery::new(10.0, 0.1, 5.0, 5.0, 1.0, 1.0, 96);
215
216        // Try to discharge at 5kW
217        let context = DeviceContext::with_setpoint(0, 5.0);
218        let actual_kw = battery.power_kw(&context);
219        assert_eq!(actual_kw, 4.0); // Should be limited by available energy
220
221        // SOC should now be 0.0 (fully discharged)
222        assert!(battery.soc < 1e-6);
223    }
224
225    #[test]
226    fn test_charge_soc_limit() {
227        // Battery at 90% SOC with 10kWh capacity (= 1kWh available space)
228        // With 0.25h timestep and perfect efficiency, max charge is 4kW
229        let mut battery = Battery::new(10.0, 0.9, 5.0, 5.0, 1.0, 1.0, 96);
230
231        // Try to charge at 5kW
232        let context = DeviceContext::with_setpoint(0, -5.0);
233        let actual_kw = battery.power_kw(&context);
234        assert!((actual_kw - (-4.0)).abs() < 1e-5); // Should be limited by available capacity
235
236        // SOC should now be 1.0 (fully charged)
237        assert!((battery.soc - 1.0).abs() < 1e-6);
238    }
239
240    #[test]
241    fn test_efficiency_charge() {
242        // Test charging with losses
243        // 10kWh battery at 0% SOC with 90% charging efficiency
244        let mut battery = Battery::new(10.0, 0.0, 5.0, 5.0, 0.9, 0.9, 4); // 6h timestep
245
246        // Charge with 1kW for 6 hours = 6kWh
247        // Should result in 6kWh * 0.9 = 5.4kWh stored
248        let context = DeviceContext::with_setpoint(0, -1.0);
249        battery.power_kw(&context);
250
251        // Expected SOC: 5.4kWh / 10kWh = 0.54
252        assert!((battery.soc - 0.54).abs() < 1e-6);
253    }
254
255    #[test]
256    fn test_efficiency_discharge() {
257        // Test discharging with losses
258        // 10kWh battery at 50% SOC with 80% discharging efficiency
259        let mut battery = Battery::new(10.0, 0.5, 5.0, 5.0, 0.9, 0.8, 4); // 6h timestep
260
261        // Discharge with 1kW for 6 hours = 6kWh
262        // Should require 6kWh / 0.8 = 7.5kWh from battery
263        let context = DeviceContext::with_setpoint(0, 1.0);
264        battery.power_kw(&context);
265
266        // Expected SOC: 0.5 - (7.5kWh / 10kWh) = 0.5 - 0.75 = -0.25, clamped to 0.0
267        assert_eq!(battery.soc, 0.0);
268    }
269
270    #[test]
271    fn test_complete_charge_discharge_cycle() {
272        // Create a 10kWh battery at 50% SOC
273        let mut battery = Battery::new(10.0, 0.5, 2.0, 2.0, 0.9, 0.9, 24); // 1h timestep
274
275        // Fully charge the battery
276        while battery.soc < 0.99 {
277            let context = DeviceContext::with_setpoint(0, -2.0);
278            battery.power_kw(&context);
279        }
280
281        // Now fully discharge
282        let mut energy_delivered = 0.0;
283        while battery.soc > 0.01 {
284            let context = DeviceContext::with_setpoint(0, 2.0);
285            let kw = battery.power_kw(&context);
286            let dt_hours = 24.0 / battery.steps_per_day as f32;
287            energy_delivered += kw * dt_hours;
288        }
289
290        // We should get approximately 10kWh * 0.9 (discharge efficiency) = 9kWh
291        assert!((energy_delivered - 9.0).abs() < 0.1);
292    }
293}