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}