vpp_sim/devices/baseload.rs
1use crate::devices::types::{Device, DeviceContext, gaussian_noise};
2use rand::{SeedableRng, rngs::StdRng};
3
4/// A baseload generator that models daily electricity consumption patterns.
5///
6/// `BaseLoad` creates a sinusoidal power demand pattern with configurable baseline,
7/// amplitude, phase, and random noise to simulate typical daily load patterns.
8///
9/// # Examples
10///
11/// Note: `vpp-sim` currently ships as a binary-first crate; this snippet is illustrative.
12/// ```ignore
13/// use vpp_sim::devices::baseload::BaseLoad;
14/// use vpp_sim::devices::types::{Device, DeviceContext};
15///
16/// // Create a baseload with typical parameters
17/// let mut load = BaseLoad::new(
18/// 1.0, // base_kw - average consumption
19/// 0.5, // amp_kw - daily variation
20/// 0.0, // phase_rad - no phase shift (minimum at midnight)
21/// 0.05, // noise_std - small random variation
22/// 24, // steps_per_day - hourly resolution
23/// 42, // seed - for reproducible randomness
24/// );
25///
26/// // Get demand at noon
27/// let demand = load.power_kw(&DeviceContext::new(12));
28/// ```
29#[derive(Debug)]
30pub struct BaseLoad {
31 /// Baseline power consumption in kilowatts
32 pub base_kw: f32,
33
34 /// Amplitude of the sinusoidal variation in kilowatts
35 pub amp_kw: f32,
36
37 /// Phase offset of the sinusoidal pattern in radians
38 pub phase_rad: f32,
39
40 /// Standard deviation of the Gaussian noise in kilowatts
41 pub noise_std: f32,
42
43 /// Number of time steps per simulated day
44 pub steps_per_day: usize,
45
46 /// Random number generator for noise generation
47 rng: StdRng,
48}
49
50impl BaseLoad {
51 /// Creates a new baseload generator with the specified parameters.
52 ///
53 /// # Arguments
54 ///
55 /// * `base_kw` - The baseline power consumption in kilowatts
56 /// * `amp_kw` - The amplitude of sinusoidal daily variation in kilowatts
57 /// * `phase_rad` - The phase offset in radians (0 = minimum at start of day)
58 /// * `noise_std` - The standard deviation of Gaussian noise in kilowatts
59 /// * `steps_per_day` - The number of time steps per simulated day
60 /// * `seed` - Random seed for reproducible noise generation
61 ///
62 /// # Returns
63 ///
64 /// A new `BaseLoad` instance configured with the specified parameters
65 pub fn new(
66 base_kw: f32,
67 amp_kw: f32,
68 phase_rad: f32,
69 noise_std: f32,
70 steps_per_day: usize,
71 seed: u64,
72 ) -> Self {
73 Self {
74 base_kw,
75 amp_kw,
76 phase_rad,
77 noise_std,
78 steps_per_day: steps_per_day.max(1),
79 rng: StdRng::seed_from_u64(seed),
80 }
81 }
82}
83
84impl Device for BaseLoad {
85 /// Calculates the power demand at a specific time step.
86 ///
87 /// This method computes the power demand as a combination of:
88 /// - A baseline component (`base_kw`)
89 /// - A sinusoidal daily pattern with specified amplitude and phase
90 /// - Random Gaussian noise with specified standard deviation
91 ///
92 /// The demand is guaranteed to be non-negative.
93 ///
94 /// # Arguments
95 ///
96 /// * `timestep` - The simulation time step
97 ///
98 /// # Returns
99 ///
100 /// The power demand in kilowatts at the specified time step
101 fn power_kw(&mut self, context: &DeviceContext) -> f32 {
102 let day_pos = (context.timestep % self.steps_per_day) as f32 / self.steps_per_day as f32; // [0,1)
103 let angle = 2.0 * std::f32::consts::PI * day_pos + self.phase_rad;
104 let sinus = angle.sin();
105
106 let noise = gaussian_noise(&mut self.rng, self.noise_std);
107 let kw = self.base_kw + self.amp_kw * sinus + noise;
108 kw.max(0.0) // no negative demand
109 }
110
111 fn device_type(&self) -> &'static str {
112 "BaseLoad"
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119 use std::f32::consts::PI;
120
121 // Helper function to create a context with just a timestep
122 fn ctx(t: usize) -> DeviceContext {
123 DeviceContext::new(t)
124 }
125
126 #[test]
127 fn test_new_baseload() {
128 let load = BaseLoad::new(1.0, 0.5, 0.0, 0.1, 24, 42);
129 assert_eq!(load.base_kw, 1.0);
130 assert_eq!(load.amp_kw, 0.5);
131 assert_eq!(load.phase_rad, 0.0);
132 assert_eq!(load.noise_std, 0.1);
133 assert_eq!(load.steps_per_day, 24);
134 }
135
136 #[test]
137 fn test_steps_per_day_minimum() {
138 // Should enforce minimum of 1 step per day
139 let load = BaseLoad::new(1.0, 0.5, 0.0, 0.1, 0, 42);
140 assert_eq!(load.steps_per_day, 1);
141 }
142
143 #[test]
144 fn test_deterministic_pattern() {
145 // With zero noise, demand should be predictable
146 let mut load = BaseLoad::new(2.0, 1.0, 0.0, 0.0, 4, 42);
147
148 // At phase 0, first step should be base_kw (since sin(0) = 0)
149 assert_eq!(load.power_kw(&ctx(0)), 2.0);
150
151 // At quarter day (π/2), should be base_kw + amp_kw (since sin(π/2) = 1)
152 let demand = load.power_kw(&ctx(1));
153 assert!((demand - 3.0).abs() < 1e-5);
154
155 // At half day (π), should be base_kw (since sin(π) = 0)
156 let demand = load.power_kw(&ctx(2));
157 assert!((demand - 2.0).abs() < 1e-5);
158
159 // At 3/4 day (3π/2), should be base_kw - amp_kw (since sin(3π/2) = -1)
160 let demand = load.power_kw(&ctx(3));
161 assert!((demand - 1.0).abs() < 1e-5);
162 }
163
164 #[test]
165 fn test_phase_shift() {
166 // Test with phase shift of π/2
167 let mut load = BaseLoad::new(2.0, 1.0, PI / 2.0, 0.0, 4, 42);
168
169 // At phase π/2, first step should be base_kw + amp_kw (since sin(π/2) = 1)
170 let demand = load.power_kw(&ctx(0));
171 assert!((demand - 3.0).abs() < 1e-5);
172 }
173
174 #[test]
175 fn test_no_negative_demand() {
176 // Configure for potential negative values
177 let mut load = BaseLoad::new(0.5, 1.0, 0.0, 0.0, 4, 42);
178
179 // At 3/4 day (3π/2), base_kw - amp_kw would be negative, but should be clamped to 0
180 let demand = load.power_kw(&ctx(3));
181 assert_eq!(demand, 0.0);
182 }
183
184 #[test]
185 fn test_random_noise_deterministic() {
186 // Same seed should produce same sequence of values
187 let mut load1 = BaseLoad::new(1.0, 0.0, 0.0, 0.5, 10, 42);
188 let mut load2 = BaseLoad::new(1.0, 0.0, 0.0, 0.5, 10, 42);
189
190 for i in 0..5 {
191 assert_eq!(load1.power_kw(&ctx(i)), load2.power_kw(&ctx(i)));
192 }
193 }
194
195 #[test]
196 fn test_different_seeds_produce_different_results() {
197 // Different seeds should produce different sequences
198 let mut load1 = BaseLoad::new(1.0, 0.0, 0.0, 0.5, 10, 42);
199 let mut load2 = BaseLoad::new(1.0, 0.0, 0.0, 0.5, 10, 43);
200
201 let mut all_same = true;
202 for i in 0..5 {
203 if (load1.power_kw(&ctx(i)) - load2.power_kw(&ctx(i))).abs() > 1e-5 {
204 all_same = false;
205 break;
206 }
207 }
208
209 assert!(!all_same);
210 }
211}