vpp_sim/devices/solar.rs
1use crate::devices::types::{Device, DeviceContext, gaussian_noise};
2use rand::{SeedableRng, rngs::StdRng};
3
4/// A solar PV generator that models power generation based on daylight hours.
5///
6/// `SolarPv` creates a half-cosine shaped generation profile between sunrise and sunset
7/// times with configurable peak power output and random noise to simulate
8/// variations due to weather conditions.
9///
10/// # Examples
11///
12/// Note: `vpp-sim` currently ships as a binary-first crate; this snippet is illustrative.
13/// ```ignore
14/// use vpp_sim::devices::solar::SolarPv;
15/// use vpp_sim::devices::types::{Device, DeviceContext};
16///
17/// // Create a solar PV system (5kW peak, 24 steps per day, sunrise at 6am, sunset at 6pm)
18/// let mut pv = SolarPv::new(
19/// 5.0, // kw_peak - maximum output in ideal conditions
20/// 24, // steps_per_day - hourly resolution
21/// 6, // sunrise_idx - 6am sunrise
22/// 18, // sunset_idx - 6pm sunset
23/// 0.05, // noise_std - small random variation for cloud cover
24/// 42, // seed - for reproducible randomness
25/// );
26///
27/// // Get generation at noon (step 12)
28/// let generation = pv.power_kw(&DeviceContext::new(12));
29/// ```
30#[derive(Debug)]
31pub struct SolarPv {
32 /// Maximum power output in kilowatts under ideal conditions
33 pub kw_peak: f32,
34
35 /// Number of time steps per simulated day
36 pub steps_per_day: usize,
37
38 /// Time step index when sunrise occurs (inclusive)
39 pub sunrise_idx: usize, // inclusive
40
41 /// Time step index when sunset occurs (exclusive)
42 pub sunset_idx: usize, // exclusive
43
44 /// Standard deviation of the Gaussian noise as a fraction of output
45 pub noise_std: f32, // e.g. 0.05 for +/-5% (Gaussian-ish)
46
47 /// Random number generator for noise generation
48 rng: StdRng,
49}
50
51impl SolarPv {
52 /// Creates a new solar PV generator with the specified parameters.
53 ///
54 /// # Arguments
55 ///
56 /// * `kw_peak` - Maximum power output in kilowatts under ideal conditions
57 /// * `steps_per_day` - Number of time steps per simulated day
58 /// * `sunrise_idx` - Time step index when sunrise occurs (inclusive)
59 /// * `sunset_idx` - Time step index when sunset occurs (exclusive)
60 /// * `noise_std` - Standard deviation of noise (e.g., 0.05 for ±5% variation)
61 /// * `seed` - Random seed for reproducible noise generation
62 ///
63 /// # Panics
64 ///
65 /// This function will panic if:
66 /// - `steps_per_day` is zero
67 /// - `sunrise_idx` is greater than `sunset_idx`
68 /// - `sunset_idx` exceeds `steps_per_day`
69 ///
70 /// # Returns
71 ///
72 /// A new `SolarPv` instance configured with the specified parameters
73 pub fn new(
74 kw_peak: f32,
75 steps_per_day: usize,
76 sunrise_idx: usize,
77 sunset_idx: usize,
78 noise_std: f32,
79 seed: u64,
80 ) -> Self {
81 assert!(steps_per_day > 0);
82 assert!(sunrise_idx < sunset_idx && sunset_idx <= steps_per_day);
83 Self {
84 kw_peak: kw_peak.max(0.0),
85 steps_per_day,
86 sunrise_idx,
87 sunset_idx,
88 noise_std: noise_std.max(0.0),
89 rng: StdRng::seed_from_u64(seed),
90 }
91 }
92
93 /// Calculates the daylight fraction for a specific time step.
94 ///
95 /// Returns a value between 0.0 and 1.0 representing the relative
96 /// solar intensity, following a half-cosine shape from sunrise to sunset.
97 /// Returns 0.0 during nighttime hours.
98 ///
99 /// # Arguments
100 ///
101 /// * `t` - The simulation time step
102 ///
103 /// # Returns
104 ///
105 /// A fraction between 0.0 and 1.0 representing the relative solar intensity
106 fn daylight_frac(&self, t: usize) -> f32 {
107 let day_t = t % self.steps_per_day;
108 if day_t < self.sunrise_idx || day_t >= self.sunset_idx {
109 return 0.0;
110 }
111 let span = (self.sunset_idx - self.sunrise_idx) as f32;
112 let x = (day_t - self.sunrise_idx) as f32 / span; // [0,1)
113 // Half-cosine dome: 0 -> 1 -> 0 across daylight
114 0.5 * (1.0 - (2.0 * std::f32::consts::PI * x).cos())
115 }
116}
117
118impl Device for SolarPv {
119 /// Calculates the power generation at a specific time step.
120 ///
121 /// This method computes the power generation as a combination of:
122 /// - Base solar output following a half-cosine curve during daylight hours
123 /// - Random Gaussian noise to simulate variations due to cloud cover
124 ///
125 /// The generation is guaranteed to be non-negative.
126 ///
127 /// # Arguments
128 ///
129 /// * `timestep` - The simulation time step
130 ///
131 /// # Returns
132 ///
133 /// The power generation in kilowatts at the specified time step
134 fn power_kw(&mut self, context: &DeviceContext) -> f32 {
135 let frac = self.daylight_frac(context.timestep);
136 if frac <= 0.0 {
137 return 0.0;
138 }
139
140 let noise_mult = 1.0 + gaussian_noise(&mut self.rng, self.noise_std);
141 let kw = self.kw_peak * frac * noise_mult;
142
143 // Return positive for generation (according to power flow convention)
144 kw.max(0.0)
145 }
146
147 fn device_type(&self) -> &'static str {
148 "SolarPV"
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 // Helper function to create a context with just a timestep
157 fn ctx(t: usize) -> DeviceContext {
158 DeviceContext::new(t)
159 }
160
161 #[test]
162 fn test_new_solar_pv() {
163 let pv = SolarPv::new(5.0, 24, 6, 18, 0.05, 42);
164 assert_eq!(pv.kw_peak, 5.0);
165 assert_eq!(pv.steps_per_day, 24);
166 assert_eq!(pv.sunrise_idx, 6);
167 assert_eq!(pv.sunset_idx, 18);
168 assert_eq!(pv.noise_std, 0.05);
169 }
170
171 #[test]
172 fn test_negative_kw_peak_clamped_to_zero() {
173 let pv = SolarPv::new(-1.0, 24, 6, 18, 0.05, 42);
174 assert_eq!(pv.kw_peak, 0.0);
175 }
176
177 #[test]
178 fn test_negative_noise_std_clamped_to_zero() {
179 let pv = SolarPv::new(5.0, 24, 6, 18, -0.05, 42);
180 assert_eq!(pv.noise_std, 0.0);
181 }
182
183 #[test]
184 #[should_panic]
185 fn test_zero_steps_per_day_panics() {
186 SolarPv::new(5.0, 0, 0, 1, 0.05, 42);
187 }
188
189 #[test]
190 #[should_panic]
191 fn test_sunset_before_sunrise_panics() {
192 SolarPv::new(5.0, 24, 18, 6, 0.05, 42);
193 }
194
195 #[test]
196 #[should_panic]
197 fn test_sunset_exceeds_steps_panics() {
198 SolarPv::new(5.0, 24, 6, 25, 0.05, 42);
199 }
200
201 #[test]
202 fn test_daylight_frac() {
203 let pv = SolarPv::new(5.0, 24, 6, 18, 0.0, 42);
204
205 // Night hours return 0.0
206 assert_eq!(pv.daylight_frac(0), 0.0); // Midnight
207 assert_eq!(pv.daylight_frac(5), 0.0); // 5am
208 assert_eq!(pv.daylight_frac(18), 0.0); // 6pm
209 assert_eq!(pv.daylight_frac(23), 0.0); // 11pm
210
211 // Dawn starts with near-zero
212 let dawn_frac = dbg!(pv.daylight_frac(6));
213 assert!(dawn_frac < 0.1);
214
215 // Noon (12) should be near peak (max value)
216 let noon_frac = pv.daylight_frac(12);
217 assert!(noon_frac > 0.95);
218
219 // Should be symmetric around noon
220 assert!((pv.daylight_frac(9) - pv.daylight_frac(15)).abs() < 1e-5);
221 }
222
223 #[test]
224 fn test_no_generation_at_night() {
225 let mut pv = SolarPv::new(5.0, 24, 6, 18, 0.0, 42);
226
227 // No generation during night hours
228 assert_eq!(pv.power_kw(&ctx(0)), 0.0); // Midnight
229 assert_eq!(pv.power_kw(&ctx(5)), 0.0); // 5am
230 assert_eq!(pv.power_kw(&ctx(18)), 0.0); // 6pm
231 assert_eq!(pv.power_kw(&ctx(23)), 0.0); // 11pm
232 }
233
234 #[test]
235 fn test_peak_generation_at_noon() {
236 let mut pv = SolarPv::new(5.0, 24, 6, 18, 0.0, 42);
237
238 // With noise_std = 0, noon should generate close to peak
239 let noon_gen = pv.power_kw(&ctx(12)); // power_kw returns positive for generation
240 assert!(noon_gen > 4.9 && noon_gen <= 5.0);
241 }
242
243 #[test]
244 fn test_deterministic_with_same_seed() {
245 let mut pv1 = SolarPv::new(5.0, 24, 6, 18, 0.1, 42);
246 let mut pv2 = SolarPv::new(5.0, 24, 6, 18, 0.1, 42);
247
248 // Same seed should produce identical generation
249 for t in 0..24 {
250 assert_eq!(pv1.power_kw(&ctx(t)), pv2.power_kw(&ctx(t)));
251 }
252 }
253
254 #[test]
255 fn test_different_seeds_produce_different_results() {
256 let mut pv1 = SolarPv::new(5.0, 24, 6, 18, 0.1, 42);
257 let mut pv2 = SolarPv::new(5.0, 24, 6, 18, 0.1, 43);
258
259 // Different seeds should produce different generation
260 let mut all_same = true;
261 for t in 6..18 {
262 // Check only daylight hours
263 if (pv1.power_kw(&ctx(t)) - pv2.power_kw(&ctx(t))).abs() > 1e-5 {
264 all_same = false;
265 break;
266 }
267 }
268
269 assert!(!all_same);
270 }
271
272 #[test]
273 fn test_multi_day_cycle() {
274 let mut pv = SolarPv::new(5.0, 24, 6, 18, 0.0, 42);
275
276 // Generation should repeat in daily cycles
277 for t in 0..24 {
278 assert_eq!(pv.power_kw(&ctx(t)), pv.power_kw(&ctx(t + 24)));
279 }
280 }
281}