ems_model/building/
electricity.rs

1/// Represents different types of electricity rate structures
2#[derive(Debug, Clone, PartialEq)]
3pub enum ElectricityRate {
4    /// Fixed rate for all hours
5    Fixed {
6        /// The rate per unit of electricity
7        rate: f64,
8    },
9    /// Tiered rate structure with different rates for different time periods
10    Tiered {
11        /// List of rate tiers
12        tiers: Vec<RateTier>,
13    },
14}
15
16/// Represents a single tier in a tiered rate structure
17#[derive(Debug, Clone, PartialEq)]
18pub struct RateTier {
19    /// Name of the tier (e.g., "Peak", "Off-Peak", "Super Off-Peak")
20    pub name: String,
21    /// Rate per unit of electricity for this tier
22    pub rate: f64,
23    /// List of hour ranges when this tier applies
24    pub hour_ranges: Vec<HourRange>,
25}
26
27/// Represents a time range when a rate tier applies
28#[derive(Debug, Clone, PartialEq)]
29pub struct HourRange {
30    /// Starting hour (0-23)
31    pub from: u8,
32    /// Ending hour (0-23, exclusive)
33    pub till: u8,
34    /// Type of day this range applies to
35    pub weekday_type: WeekdayType,
36}
37
38/// Represents the type of day for rate application
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub enum WeekdayType {
41    /// Monday through Friday
42    Weekday,
43    /// Saturday and Sunday
44    Weekend,
45}
46
47impl ElectricityRate {
48    /// Creates a new fixed electricity rate
49    pub fn fixed(rate: f64) -> Self {
50        Self::Fixed { rate }
51    }
52
53    /// Creates a new tiered electricity rate
54    pub fn tiered(tiers: Vec<RateTier>) -> Self {
55        Self::Tiered { tiers }
56    }
57
58    /// Converts the electricity rate to a vector of hourly rates for a single week
59    /// Returns a Vec<f64> with 168 elements (24 hours × 7 days)
60    /// The vector is organized as: [Mon 0h, Mon 1h, ..., Mon 23h, Tue 0h, ..., Sun 23h]
61    pub fn to_weekly_hourly_rates(&self) -> Vec<f64> {
62        let mut weekly_rates = Vec::with_capacity(168);
63
64        // Days of the week: 0=Monday, 1=Tuesday, ..., 6=Sunday
65        for day in 0..7 {
66            let weekday_type = if day < 5 {
67                WeekdayType::Weekday
68            } else {
69                WeekdayType::Weekend
70            };
71
72            for hour in 0..24 {
73                let rate = self.get_rate_for_hour(hour, weekday_type);
74                weekly_rates.push(rate);
75            }
76        }
77
78        weekly_rates
79    }
80
81    /// Converts the electricity rate to a vector of hourly rates for the whole year
82    /// Returns a Vec<f64> with 8760 elements (24 hours × 365 days)
83    /// The vector is organized as: [Jan 1 0h, Jan 1 1h, ..., Dec 31 23h]
84    pub fn to_yearly_hourly_rates(&self) -> Vec<f64> {
85        let mut yearly_rates = Vec::with_capacity(8760);
86
87        // Generate rates for each day of the year
88        for day_of_year in 0..365 {
89            let weekday_type = self.get_weekday_type_for_day_of_year(day_of_year);
90
91            for hour in 0..24 {
92                let rate = self.get_rate_for_hour(hour, weekday_type);
93                yearly_rates.push(rate);
94            }
95        }
96
97        yearly_rates
98    }
99
100    /// Gets the rate for a specific hour and day type
101    fn get_rate_for_hour(&self, hour: u8, weekday_type: WeekdayType) -> f64 {
102        match self {
103            ElectricityRate::Fixed { rate } => *rate,
104            ElectricityRate::Tiered { tiers } => {
105                // Find the first tier that matches this hour and day type
106                for tier in tiers {
107                    if tier.matches_hour(hour, weekday_type) {
108                        return tier.rate;
109                    }
110                }
111                // If no tier matches, return 0.0 (or could panic/return error)
112                0.0
113            }
114        }
115    }
116
117    /// Determines the weekday type for a given day of the year
118    /// Assumes January 1st is a Monday (day 0)
119    fn get_weekday_type_for_day_of_year(&self, day_of_year: u16) -> WeekdayType {
120        // January 1st is day 0, which we assume is Monday
121        // So day % 7 gives us: 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Sat, 6=Sun
122        let day_of_week = day_of_year % 7;
123        if day_of_week < 5 {
124            WeekdayType::Weekday
125        } else {
126            WeekdayType::Weekend
127        }
128    }
129
130    /// Validates that all weekend and weekday hours are covered exactly once
131    /// Returns true if the rate structure is valid, false otherwise
132    pub fn is_valid(&self) -> bool {
133        match self {
134            ElectricityRate::Fixed { .. } => {
135                // Fixed rates are always valid as they cover all hours
136                true
137            }
138            ElectricityRate::Tiered { tiers } => {
139                // Check if all hours (0-23) are covered exactly once for both weekday types
140                self.validate_weekday_coverage(tiers) && self.validate_weekend_coverage(tiers)
141            }
142        }
143    }
144
145    /// Validates that all weekday hours (0-23) are covered exactly once
146    fn validate_weekday_coverage(&self, tiers: &[RateTier]) -> bool {
147        let mut covered_hours = [false; 24];
148
149        for tier in tiers {
150            for hour_range in &tier.hour_ranges {
151                if hour_range.weekday_type == WeekdayType::Weekday {
152                    if !self.mark_hours_covered(&mut covered_hours, hour_range) {
153                        return false; // Overlapping hours detected
154                    }
155                }
156            }
157        }
158
159        // Check if all hours are covered
160        covered_hours.iter().all(|&covered| covered)
161    }
162
163    /// Validates that all weekend hours (0-23) are covered exactly once
164    fn validate_weekend_coverage(&self, tiers: &[RateTier]) -> bool {
165        let mut covered_hours = [false; 24];
166
167        for tier in tiers {
168            for hour_range in &tier.hour_ranges {
169                if hour_range.weekday_type == WeekdayType::Weekend {
170                    if !self.mark_hours_covered(&mut covered_hours, hour_range) {
171                        return false; // Overlapping hours detected
172                    }
173                }
174            }
175        }
176
177        // Check if all hours are covered
178        covered_hours.iter().all(|&covered| covered)
179    }
180
181    /// Marks hours as covered in the given array and returns false if any overlap is detected
182    fn mark_hours_covered(&self, covered_hours: &mut [bool; 24], hour_range: &HourRange) -> bool {
183        if hour_range.from > hour_range.till {
184            // Wrapping range (e.g., 22:00 to 06:00)
185            for hour in hour_range.from..24 {
186                if covered_hours[hour as usize] {
187                    return false; // Overlap detected
188                }
189                covered_hours[hour as usize] = true;
190            }
191            for hour in 0..hour_range.till {
192                if covered_hours[hour as usize] {
193                    return false; // Overlap detected
194                }
195                covered_hours[hour as usize] = true;
196            }
197        } else {
198            // Normal range (e.g., 09:00 to 17:00)
199            for hour in hour_range.from..hour_range.till {
200                if covered_hours[hour as usize] {
201                    return false; // Overlap detected
202                }
203                covered_hours[hour as usize] = true;
204            }
205        }
206        true
207    }
208}
209
210impl RateTier {
211    /// Creates a new rate tier
212    pub fn new(name: String, rate: f64, hour_ranges: Vec<HourRange>) -> Self {
213        Self {
214            name,
215            rate,
216            hour_ranges,
217        }
218    }
219
220    /// Checks if this tier applies to the given hour and day type
221    pub fn matches_hour(&self, hour: u8, weekday_type: WeekdayType) -> bool {
222        self.hour_ranges
223            .iter()
224            .any(|range| range.matches_hour(hour, weekday_type))
225    }
226}
227
228impl HourRange {
229    /// Creates a new hour range
230    pub fn new(from: u8, till: u8, weekday_type: WeekdayType) -> Self {
231        Self {
232            from,
233            till,
234            weekday_type,
235        }
236    }
237
238    /// Checks if this hour range matches the given hour and day type
239    pub fn matches_hour(&self, hour: u8, weekday_type: WeekdayType) -> bool {
240        // First check if the weekday type matches
241        if self.weekday_type != weekday_type {
242            return false;
243        }
244
245        // Handle the case where the range wraps around midnight (e.g., 22:00 to 06:00)
246        if self.from > self.till {
247            // Wrapping range: from > till (e.g., 22:00 to 06:00)
248            hour >= self.from || hour < self.till
249        } else {
250            // Normal range: from <= till (e.g., 09:00 to 17:00)
251            hour >= self.from && hour < self.till
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_fixed_rate() {
262        let rate = ElectricityRate::fixed(0.12);
263        match rate {
264            ElectricityRate::Fixed { rate: r } => assert_eq!(r, 0.12),
265            _ => panic!("Expected Fixed rate"),
266        }
267    }
268
269    #[test]
270    fn test_tiered_rate() {
271        let peak_tier = RateTier::new(
272            "Peak".to_string(),
273            0.25,
274            vec![
275                HourRange::new(9, 17, WeekdayType::Weekday),
276                HourRange::new(10, 16, WeekdayType::Weekend),
277            ],
278        );
279
280        let off_peak_tier = RateTier::new(
281            "Off-Peak".to_string(),
282            0.08,
283            vec![
284                HourRange::new(17, 9, WeekdayType::Weekday),
285                HourRange::new(16, 10, WeekdayType::Weekend),
286            ],
287        );
288
289        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
290        match rate {
291            ElectricityRate::Tiered { tiers } => assert_eq!(tiers.len(), 2),
292            _ => panic!("Expected Tiered rate"),
293        }
294    }
295
296    #[test]
297    fn test_fixed_rate_weekly_conversion() {
298        let rate = ElectricityRate::fixed(0.15);
299        let weekly_rates = rate.to_weekly_hourly_rates();
300
301        // Should have 168 elements (24 hours × 7 days)
302        assert_eq!(weekly_rates.len(), 168);
303
304        // All rates should be the same (0.15)
305        for &rate_value in &weekly_rates {
306            assert_eq!(rate_value, 0.15);
307        }
308    }
309
310    #[test]
311    fn test_fixed_rate_yearly_conversion() {
312        let rate = ElectricityRate::fixed(0.15);
313        let yearly_rates = rate.to_yearly_hourly_rates();
314
315        // Should have 8760 elements (24 hours × 365 days)
316        assert_eq!(yearly_rates.len(), 8760);
317
318        // All rates should be the same (0.15)
319        for &rate_value in &yearly_rates {
320            assert_eq!(rate_value, 0.15);
321        }
322    }
323
324    #[test]
325    fn test_tiered_rate_weekly_conversion() {
326        let peak_tier = RateTier::new(
327            "Peak".to_string(),
328            0.25,
329            vec![HourRange::new(9, 17, WeekdayType::Weekday)],
330        );
331
332        let off_peak_tier = RateTier::new(
333            "Off-Peak".to_string(),
334            0.08,
335            vec![
336                HourRange::new(17, 9, WeekdayType::Weekday),
337                HourRange::new(0, 24, WeekdayType::Weekend),
338            ],
339        );
340
341        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
342        let weekly_rates = rate.to_weekly_hourly_rates();
343
344        // Should have 168 elements
345        assert_eq!(weekly_rates.len(), 168);
346
347        // Check weekday rates (Monday-Friday, indices 0-119)
348        for day in 0..5 {
349            for hour in 0..24 {
350                let index = day * 24 + hour;
351                if hour >= 9 && hour < 17 {
352                    // Peak hours
353                    assert_eq!(weekly_rates[index], 0.25);
354                } else {
355                    // Off-peak hours
356                    assert_eq!(weekly_rates[index], 0.08);
357                }
358            }
359        }
360
361        // Check weekend rates (Saturday-Sunday, indices 120-167)
362        for day in 5..7 {
363            for hour in 0..24 {
364                let index = day * 24 + hour;
365                // All weekend hours should be off-peak
366                assert_eq!(weekly_rates[index], 0.08);
367            }
368        }
369    }
370
371    #[test]
372    fn test_hour_range_matching() {
373        // Test normal range (9:00 to 17:00)
374        let range = HourRange::new(9, 17, WeekdayType::Weekday);
375
376        // Should match hours 9-16
377        for hour in 9..17 {
378            assert!(range.matches_hour(hour, WeekdayType::Weekday));
379        }
380
381        // Should not match hours outside range
382        for hour in 0..9 {
383            assert!(!range.matches_hour(hour, WeekdayType::Weekday));
384        }
385        for hour in 17..24 {
386            assert!(!range.matches_hour(hour, WeekdayType::Weekday));
387        }
388
389        // Should not match different weekday type
390        assert!(!range.matches_hour(10, WeekdayType::Weekend));
391    }
392
393    #[test]
394    fn test_wrapping_hour_range() {
395        // Test wrapping range (22:00 to 06:00)
396        let range = HourRange::new(22, 6, WeekdayType::Weekday);
397
398        // Should match hours 22-23 and 0-5
399        for hour in 22..24 {
400            assert!(range.matches_hour(hour, WeekdayType::Weekday));
401        }
402        for hour in 0..6 {
403            assert!(range.matches_hour(hour, WeekdayType::Weekday));
404        }
405
406        // Should not match hours 6-21
407        for hour in 6..22 {
408            assert!(!range.matches_hour(hour, WeekdayType::Weekday));
409        }
410    }
411
412    #[test]
413    fn test_weekday_type_determination() {
414        let rate = ElectricityRate::fixed(0.1);
415
416        // Test first few days of the year (assuming Jan 1 is Monday)
417        // Day 0 (Jan 1) should be Monday (Weekday)
418        let weekday_type = rate.get_weekday_type_for_day_of_year(0);
419        assert_eq!(weekday_type, WeekdayType::Weekday);
420
421        // Day 4 (Jan 5) should be Friday (Weekday)
422        let weekday_type = rate.get_weekday_type_for_day_of_year(4);
423        assert_eq!(weekday_type, WeekdayType::Weekday);
424
425        // Day 5 (Jan 6) should be Saturday (Weekend)
426        let weekday_type = rate.get_weekday_type_for_day_of_year(5);
427        assert_eq!(weekday_type, WeekdayType::Weekend);
428
429        // Day 6 (Jan 7) should be Sunday (Weekend)
430        let weekday_type = rate.get_weekday_type_for_day_of_year(6);
431        assert_eq!(weekday_type, WeekdayType::Weekend);
432
433        // Day 7 (Jan 8) should be Monday (Weekday)
434        let weekday_type = rate.get_weekday_type_for_day_of_year(7);
435        assert_eq!(weekday_type, WeekdayType::Weekday);
436    }
437
438    #[test]
439    fn test_fixed_rate_is_valid() {
440        let rate = ElectricityRate::fixed(0.12);
441        assert!(rate.is_valid());
442    }
443
444    #[test]
445    fn test_valid_tiered_rate() {
446        // Valid tiered rate with complete coverage
447        let peak_tier = RateTier::new(
448            "Peak".to_string(),
449            0.25,
450            vec![HourRange::new(9, 17, WeekdayType::Weekday)],
451        );
452
453        let off_peak_tier = RateTier::new(
454            "Off-Peak".to_string(),
455            0.08,
456            vec![
457                HourRange::new(17, 9, WeekdayType::Weekday), // Wrapping range for weekdays
458                HourRange::new(0, 24, WeekdayType::Weekend), // All weekend hours
459            ],
460        );
461
462        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
463        assert!(rate.is_valid());
464    }
465
466    #[test]
467    fn test_invalid_tiered_rate_missing_weekday_hours() {
468        // Invalid tiered rate missing some weekday hours
469        let peak_tier = RateTier::new(
470            "Peak".to_string(),
471            0.25,
472            vec![HourRange::new(9, 17, WeekdayType::Weekday)], // Only covers hours 9-16
473        );
474
475        let rate = ElectricityRate::tiered(vec![peak_tier]);
476        assert!(!rate.is_valid()); // Missing weekday hours 0-8 and 17-23
477    }
478
479    #[test]
480    fn test_invalid_tiered_rate_missing_weekend_hours() {
481        // Invalid tiered rate missing weekend hours
482        let peak_tier = RateTier::new(
483            "Peak".to_string(),
484            0.25,
485            vec![HourRange::new(9, 17, WeekdayType::Weekend)], // Only covers weekend hours 9-16
486        );
487
488        let rate = ElectricityRate::tiered(vec![peak_tier]);
489        assert!(!rate.is_valid()); // Missing weekend hours 0-8 and 17-23
490    }
491
492    #[test]
493    fn test_invalid_tiered_rate_overlapping_hours() {
494        // Invalid tiered rate with overlapping hours
495        let peak_tier = RateTier::new(
496            "Peak".to_string(),
497            0.25,
498            vec![HourRange::new(9, 17, WeekdayType::Weekday)],
499        );
500
501        let off_peak_tier = RateTier::new(
502            "Off-Peak".to_string(),
503            0.08,
504            vec![HourRange::new(15, 20, WeekdayType::Weekday)], // Overlaps with peak tier
505        );
506
507        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
508        assert!(!rate.is_valid()); // Hours 15-16 are covered twice
509    }
510
511    #[test]
512    fn test_valid_tiered_rate_with_wrapping_ranges() {
513        // Valid tiered rate using wrapping ranges
514        let peak_tier = RateTier::new(
515            "Peak".to_string(),
516            0.25,
517            vec![
518                HourRange::new(9, 17, WeekdayType::Weekday),
519                HourRange::new(10, 16, WeekdayType::Weekend),
520            ],
521        );
522
523        let off_peak_tier = RateTier::new(
524            "Off-Peak".to_string(),
525            0.08,
526            vec![
527                HourRange::new(17, 9, WeekdayType::Weekday), // Wrapping: 17-23 and 0-8
528                HourRange::new(16, 10, WeekdayType::Weekend), // Wrapping: 16-23 and 0-9
529            ],
530        );
531
532        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
533        assert!(rate.is_valid());
534    }
535
536    #[test]
537    fn test_invalid_tiered_rate_wrapping_overlap() {
538        // Invalid tiered rate with wrapping ranges that overlap
539        let peak_tier = RateTier::new(
540            "Peak".to_string(),
541            0.25,
542            vec![HourRange::new(22, 6, WeekdayType::Weekday)], // Wrapping: 22-23 and 0-5
543        );
544
545        let off_peak_tier = RateTier::new(
546            "Off-Peak".to_string(),
547            0.08,
548            vec![HourRange::new(4, 8, WeekdayType::Weekday)], // Overlaps with peak at hours 4-5
549        );
550
551        let rate = ElectricityRate::tiered(vec![peak_tier, off_peak_tier]);
552        assert!(!rate.is_valid()); // Hours 4-5 are covered twice
553    }
554
555    #[test]
556    fn test_valid_tiered_rate_separate_weekday_weekend() {
557        // Valid tiered rate with completely separate weekday and weekend coverage
558        let weekday_peak = RateTier::new(
559            "Weekday Peak".to_string(),
560            0.25,
561            vec![HourRange::new(9, 17, WeekdayType::Weekday)],
562        );
563
564        let weekday_off_peak = RateTier::new(
565            "Weekday Off-Peak".to_string(),
566            0.08,
567            vec![HourRange::new(17, 9, WeekdayType::Weekday)],
568        );
569
570        let weekend_rate = RateTier::new(
571            "Weekend Rate".to_string(),
572            0.12,
573            vec![HourRange::new(0, 24, WeekdayType::Weekend)],
574        );
575
576        let rate = ElectricityRate::tiered(vec![weekday_peak, weekday_off_peak, weekend_rate]);
577        assert!(rate.is_valid());
578    }
579}