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}