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}