grid_tariffs/
peaks.rs

1use chrono::{DateTime, TimeDelta, Utc};
2use chrono_tz::Tz;
3use itertools::Itertools;
4use std::{collections::HashMap, marker::PhantomData};
5
6use crate::{
7    CostPeriod, CostPeriodMatching, CostPeriods, TariffCalculationMethod,
8    power_tariffs::PowerDivide,
9};
10
11/// Matching power tariff peaks for a given set of power averages
12#[derive(Clone, Debug)]
13pub struct PowerTariffMatches {
14    calc_method: TariffCalculationMethod,
15    cost_period_matching: CostPeriodMatching,
16    power_divide: Option<PowerDivide>,
17    items: Vec<PeriodPeakMatches>,
18    current_power_average: Option<PartialPowerAverage>,
19}
20
21impl PowerTariffMatches {
22    pub fn new(
23        calc_method: TariffCalculationMethod,
24        power_divide: Option<PowerDivide>,
25        periods: CostPeriods,
26        averages: &[PowerAverage<Virtual>],
27        current_power_average: Option<PartialPowerAverage>,
28    ) -> Self {
29        let cost_period_matching = periods.match_method();
30        let items = PeriodPeakMatches::new(calc_method, &periods, averages, cost_period_matching);
31
32        Self {
33            calc_method,
34            cost_period_matching,
35            power_divide,
36            items,
37            current_power_average,
38        }
39    }
40
41    pub fn new_dummy() -> Self {
42        Self::new(
43            TariffCalculationMethod::AverageDays(99),
44            None,
45            CostPeriods::new_first(&[]),
46            &[],
47            None,
48        )
49    }
50
51    pub fn calc_method(&self) -> TariffCalculationMethod {
52        self.calc_method
53    }
54
55    pub fn cost_period_matching(&self) -> CostPeriodMatching {
56        self.cost_period_matching
57    }
58
59    pub fn items(&self) -> &[PeriodPeakMatches] {
60        &self.items
61    }
62
63    pub fn current_power_average(&self) -> Option<PartialPowerAverage> {
64        self.current_power_average
65    }
66
67    pub fn power_divide(&self) -> Option<PowerDivide> {
68        self.power_divide
69    }
70}
71
72/// Power tariff peaks that match the given cost period
73#[derive(Clone, Debug)]
74pub struct PeriodPeakMatches {
75    period: CostPeriod,
76    peaks: PowerPeaks,
77}
78
79impl PeriodPeakMatches {
80    pub fn period(&self) -> &CostPeriod {
81        &self.period
82    }
83    pub fn peaks(&self) -> &PowerPeaks {
84        &self.peaks
85    }
86
87    fn new(
88        calc_method: TariffCalculationMethod,
89        periods: &CostPeriods,
90        averages: &[PowerAverage<Virtual>],
91        match_method: CostPeriodMatching,
92    ) -> Vec<Self> {
93        let mut used_indices = if match_method == CostPeriodMatching::First {
94            Some(std::collections::HashSet::new())
95        } else {
96            None
97        };
98
99        periods
100            .iter()
101            .map(|period| {
102                let averages_for_period: Vec<PowerAverage<Virtual>> = averages
103                    .iter()
104                    .enumerate()
105                    .filter_map(|(avg_idx, avg)| {
106                        if period.matches(avg.timestamp) {
107                            // For First matching, skip if already used
108                            if let Some(ref mut used) = used_indices {
109                                if used.contains(&avg_idx) {
110                                    return None;
111                                }
112                                used.insert(avg_idx);
113                            }
114                            Some(*avg)
115                        } else {
116                            None
117                        }
118                    })
119                    .collect();
120
121                Self {
122                    period: period.clone(),
123                    peaks: PowerPeaks::new(calc_method, &averages_for_period),
124                }
125            })
126            .collect()
127    }
128}
129
130/// Power average that is not deemed complete
131#[derive(Copy, Clone, Debug)]
132pub struct PartialPowerAverage {
133    power_average: PowerAverage<Actual>,
134    duration_secs: u16,
135}
136
137impl PartialPowerAverage {
138    pub fn new(power_average: PowerAverage<Actual>, duration_secs: u16) -> Self {
139        Self {
140            power_average,
141            duration_secs,
142        }
143    }
144
145    pub fn power_average(&self) -> PowerAverage<Actual> {
146        self.power_average
147    }
148
149    pub fn duration_secs(&self) -> u16 {
150        self.duration_secs
151    }
152
153    fn start(&self) -> DateTime<Tz> {
154        self.power_average().timestamp()
155    }
156
157    fn end(&self) -> DateTime<Tz> {
158        self.power_average().timestamp() + TimeDelta::seconds(self.duration_secs().into())
159    }
160
161    pub fn covers(&self, ts: DateTime<Utc>, secs: u16) -> bool {
162        let ts_end = ts + TimeDelta::seconds(secs.into());
163        ts >= self.start() && ts_end <= self.end()
164    }
165
166    /// Calculates what percentage of a given time range is covered by this partial power average.
167    ///
168    /// This method computes the overlap between the partial power average's time range
169    /// `[self.start(), self.end()]` and the query time range `[ts, ts + num_secs]`.
170    ///
171    /// # Arguments
172    ///
173    /// * `ts` - The start timestamp of the query range (in UTC)
174    /// * `num_secs` - The duration of the query range in seconds
175    ///
176    /// # Returns
177    ///
178    /// A percentage (0-100) representing how much of the query range is covered by this
179    /// partial power average. Returns 0 if there is no overlap.
180    ///
181    /// # Examples
182    ///
183    /// ```
184    /// // If partial average covers 10:00-11:00 (3600 seconds)
185    /// // and query is 10:15-10:45 (1800 seconds)
186    /// // Result is 100% (query range completely within partial average)
187    ///
188    /// // If partial average covers 10:00-11:00
189    /// // and query is 10:30-11:30 (3600 seconds)
190    /// // Result is 50% (1800 seconds overlap out of 3600 seconds query)
191    /// ```
192    pub fn cover_percentage(&self, ts: DateTime<Utc>, num_secs: u32) -> u8 {
193        let ts_end = ts + TimeDelta::seconds(num_secs.into());
194
195        // Convert to UTC for comparison
196        let self_start = self.start().with_timezone(&Utc);
197        let self_end = self.end().with_timezone(&Utc);
198
199        // Calculate the overlap between [ts, ts_end] and [self.start(), self.end()]
200        let overlap_start = ts.max(self_start);
201        let overlap_end = ts_end.min(self_end);
202
203        // If there's no overlap, return 0
204        if overlap_start >= overlap_end {
205            return 0;
206        }
207
208        // Calculate the number of seconds of overlap
209        let overlap_secs = (overlap_end - overlap_start).num_seconds();
210
211        // Calculate and return the percentage covered
212        (overlap_secs as f64 / num_secs as f64 * 100.0) as u8
213    }
214}
215
216pub trait PowerAverageType {}
217
218#[derive(Copy, Clone, Debug, PartialEq)]
219pub struct Virtual;
220impl PowerAverageType for Virtual {}
221
222#[derive(Copy, Clone, Debug, PartialEq)]
223pub struct Actual;
224impl PowerAverageType for Actual {}
225
226/// Average of power measurements for a certain period of time
227#[derive(Copy, Clone, Debug, PartialEq)]
228pub struct PowerAverage<T: PowerAverageType> {
229    timestamp: DateTime<Tz>,
230    value: u32,
231    _type: PhantomData<T>,
232}
233
234impl<T: PowerAverageType> PowerAverage<T> {
235    pub fn timestamp(&self) -> DateTime<Tz> {
236        self.timestamp
237    }
238
239    pub fn kw(&self) -> f64 {
240        self.value as f64 / 1000.
241    }
242}
243
244impl PowerAverage<Actual> {
245    pub fn new<Dt: Into<DateTime<Tz>>>(timestamp: Dt, value: u32) -> Self {
246        Self {
247            timestamp: timestamp.into(),
248            value,
249            _type: PhantomData,
250        }
251    }
252
253    pub fn into_virtual(self, power_divide: Option<PowerDivide>) -> PowerAverage<Virtual> {
254        let adjusted_value = if let Some(pd) = power_divide {
255            (self.value as f64 * pd.multiplier(self.timestamp)) as u32
256        } else {
257            self.value
258        };
259
260        PowerAverage {
261            timestamp: self.timestamp,
262            value: adjusted_value,
263            _type: PhantomData,
264        }
265    }
266}
267
268/// Observed power peaks
269#[derive(Default, Clone, Debug)]
270pub struct PowerPeaks(Vec<PowerAverage<Virtual>>);
271
272impl PowerPeaks {
273    pub fn new(
274        calc_method: TariffCalculationMethod,
275        period_averages: &[PowerAverage<Virtual>],
276    ) -> Self {
277        let peaks: Vec<PowerAverage<Virtual>> = match calc_method {
278            crate::TariffCalculationMethod::AverageDays(n) => {
279                // For AverageDays: get the highest hour from each of the top n days
280                let mut daily_peaks: HashMap<chrono::NaiveDate, PowerAverage<Virtual>> =
281                    HashMap::new();
282
283                // Group by day and keep only the highest value for each day
284                for power_average in period_averages {
285                    let date = power_average.timestamp.date_naive();
286                    daily_peaks
287                        .entry(date)
288                        .and_modify(|existing| {
289                            if power_average.value > existing.value {
290                                *existing = *power_average;
291                            }
292                        })
293                        .or_insert(*power_average);
294                }
295
296                // Convert to vector and sort by value descending
297                daily_peaks
298                    .into_values()
299                    .sorted_by_key(|power_average| power_average.value)
300                    .rev()
301                    .take(n as usize)
302                    .collect()
303            }
304            crate::TariffCalculationMethod::AverageHours(n) => {
305                // For AverageHours: get the top n hours by value
306                period_averages
307                    .iter()
308                    .sorted_by_key(|power_average| power_average.value)
309                    .rev()
310                    .take(n as usize)
311                    .copied()
312                    .collect()
313            }
314        };
315
316        Self(peaks)
317    }
318
319    pub fn values(&self) -> &[PowerAverage<Virtual>] {
320        &self.0
321    }
322
323    pub fn min(&self) -> Option<PowerAverage<Virtual>> {
324        self.values().last().copied()
325    }
326
327    pub fn max(&self) -> Option<PowerAverage<Virtual>> {
328        self.values().first().copied()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::{Country, LoadType, Stockholm, months::Month};
336    use chrono::{Datelike, Timelike};
337
338    #[test]
339    fn average_hours_returns_n_highest_values() {
340        let averages = vec![
341            PowerAverage::new(Stockholm.dt(2025, 1, 1, 0, 0, 0), 100),
342            PowerAverage::new(Stockholm.dt(2025, 1, 1, 1, 0, 0), 500),
343            PowerAverage::new(Stockholm.dt(2025, 1, 1, 2, 0, 0), 300),
344            PowerAverage::new(Stockholm.dt(2025, 1, 1, 3, 0, 0), 200),
345            PowerAverage::new(Stockholm.dt(2025, 1, 1, 4, 0, 0), 400),
346        ];
347
348        let peaks = PowerPeaks::new(
349            TariffCalculationMethod::AverageHours(3),
350            &averages
351                .iter()
352                .map(|a| a.into_virtual(None))
353                .collect::<Vec<_>>(),
354        );
355
356        assert_eq!(peaks.values().len(), 3);
357        assert_eq!(peaks.values()[0].value, 500);
358        assert_eq!(peaks.values()[1].value, 400);
359        assert_eq!(peaks.values()[2].value, 300);
360    }
361
362    #[test]
363    fn average_hours_with_equal_values() {
364        let averages = vec![
365            PowerAverage::new(Stockholm.dt(2025, 1, 1, 0, 0, 0), 500),
366            PowerAverage::new(Stockholm.dt(2025, 1, 1, 1, 0, 0), 500),
367            PowerAverage::new(Stockholm.dt(2025, 1, 1, 2, 0, 0), 300),
368        ];
369
370        let peaks = PowerPeaks::new(
371            TariffCalculationMethod::AverageHours(2),
372            &averages
373                .iter()
374                .map(|a| a.into_virtual(None))
375                .collect::<Vec<_>>(),
376        );
377
378        assert_eq!(peaks.values().len(), 2);
379        assert_eq!(peaks.values()[0].value, 500);
380        assert_eq!(peaks.values()[1].value, 500);
381    }
382
383    #[test]
384    fn average_hours_empty_input() {
385        let averages = vec![];
386
387        let peaks = PowerPeaks::new(TariffCalculationMethod::AverageHours(3), &averages);
388
389        assert_eq!(peaks.values().len(), 0);
390    }
391
392    #[test]
393    fn average_hours_zero_n() {
394        let averages = vec![PowerAverage::new(Stockholm.dt(2025, 1, 1, 0, 0, 0), 100)];
395
396        let peaks = PowerPeaks::new(
397            TariffCalculationMethod::AverageHours(0),
398            &averages
399                .iter()
400                .map(|a| a.into_virtual(None))
401                .collect::<Vec<_>>(),
402        );
403
404        assert_eq!(peaks.values().len(), 0);
405    }
406
407    #[test]
408    fn average_hours_n_greater_than_available() {
409        let averages = vec![
410            PowerAverage::new(Stockholm.dt(2025, 1, 1, 0, 0, 0), 100),
411            PowerAverage::new(Stockholm.dt(2025, 1, 1, 1, 0, 0), 200),
412        ];
413
414        let peaks = PowerPeaks::new(
415            TariffCalculationMethod::AverageHours(5),
416            &averages
417                .iter()
418                .map(|a| a.into_virtual(None))
419                .collect::<Vec<_>>(),
420        );
421
422        assert_eq!(peaks.values().len(), 2);
423        assert_eq!(peaks.values()[0].value, 200);
424        assert_eq!(peaks.values()[1].value, 100);
425    }
426
427    #[test]
428    fn average_days_one_peak_per_day() {
429        let averages = vec![
430            // Day 1: peak at 500
431            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
432            PowerAverage::new(Stockholm.dt(2025, 1, 1, 11, 0, 0), 500),
433            PowerAverage::new(Stockholm.dt(2025, 1, 1, 12, 0, 0), 300),
434            // Day 2: peak at 600
435            PowerAverage::new(Stockholm.dt(2025, 1, 2, 10, 0, 0), 200),
436            PowerAverage::new(Stockholm.dt(2025, 1, 2, 11, 0, 0), 600),
437            // Day 3: peak at 250
438            PowerAverage::new(Stockholm.dt(2025, 1, 3, 10, 0, 0), 150),
439            PowerAverage::new(Stockholm.dt(2025, 1, 3, 11, 0, 0), 250),
440        ];
441
442        let peaks = PowerPeaks::new(
443            TariffCalculationMethod::AverageDays(2),
444            &averages
445                .iter()
446                .map(|a| a.into_virtual(None))
447                .collect::<Vec<_>>(),
448        );
449
450        assert_eq!(peaks.values().len(), 2);
451        assert_eq!(peaks.values()[0].value, 600);
452        assert_eq!(peaks.values()[0].timestamp.day(), 2);
453        assert_eq!(peaks.values()[1].value, 500);
454        assert_eq!(peaks.values()[1].timestamp.day(), 1);
455    }
456
457    #[test]
458    fn average_days_ensures_different_days() {
459        let averages = vec![
460            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 500),
461            PowerAverage::new(Stockholm.dt(2025, 1, 1, 11, 0, 0), 450),
462            PowerAverage::new(Stockholm.dt(2025, 1, 2, 10, 0, 0), 300),
463        ];
464
465        let peaks = PowerPeaks::new(
466            TariffCalculationMethod::AverageDays(2),
467            &averages
468                .iter()
469                .map(|a| a.into_virtual(None))
470                .collect::<Vec<_>>(),
471        );
472
473        assert_eq!(peaks.values().len(), 2);
474        let day1 = peaks.values()[0].timestamp.date_naive();
475        let day2 = peaks.values()[1].timestamp.date_naive();
476        assert_ne!(day1, day2);
477    }
478
479    #[test]
480    fn average_days_preserves_peak_hour_timestamp() {
481        let averages = vec![
482            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
483            PowerAverage::new(Stockholm.dt(2025, 1, 1, 14, 0, 0), 500),
484            PowerAverage::new(Stockholm.dt(2025, 1, 1, 20, 0, 0), 300),
485        ];
486
487        let peaks = PowerPeaks::new(
488            TariffCalculationMethod::AverageDays(1),
489            &averages
490                .iter()
491                .map(|a| a.into_virtual(None))
492                .collect::<Vec<_>>(),
493        );
494
495        assert_eq!(peaks.values().len(), 1);
496        assert_eq!(peaks.values()[0].timestamp.hour(), 14);
497        assert_eq!(peaks.values()[0].value, 500);
498    }
499
500    #[test]
501    fn average_days_empty_input() {
502        let averages = vec![];
503
504        let peaks = PowerPeaks::new(TariffCalculationMethod::AverageDays(3), &averages);
505
506        assert_eq!(peaks.values().len(), 0);
507    }
508
509    #[test]
510    fn average_days_zero_n() {
511        let averages = vec![PowerAverage::new(Stockholm.dt(2025, 1, 1, 0, 0, 0), 100)];
512
513        let peaks = PowerPeaks::new(
514            TariffCalculationMethod::AverageDays(0),
515            &averages
516                .iter()
517                .map(|a| a.into_virtual(None))
518                .collect::<Vec<_>>(),
519        );
520
521        assert_eq!(peaks.values().len(), 0);
522    }
523
524    #[test]
525    fn average_days_n_greater_than_available_days() {
526        let averages = vec![
527            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
528            PowerAverage::new(Stockholm.dt(2025, 1, 2, 10, 0, 0), 200),
529        ];
530
531        let peaks = PowerPeaks::new(
532            TariffCalculationMethod::AverageDays(5),
533            &averages
534                .iter()
535                .map(|a| a.into_virtual(None))
536                .collect::<Vec<_>>(),
537        );
538
539        assert_eq!(peaks.values().len(), 2);
540    }
541
542    #[test]
543    fn peak_periods_first_matching_splits_values() {
544        static PERIODS_ARRAY: [CostPeriod; 2] = [
545            CostPeriod::builder()
546                .load(LoadType::High)
547                .fixed_cost(10, 0)
548                .hours(6, 22)
549                .months(Month::November, Month::March)
550                .exclude_weekends()
551                .exclude_holidays(Country::SE)
552                .build(),
553            CostPeriod::builder()
554                .load(LoadType::Low)
555                .fixed_cost(5, 0)
556                .build(),
557        ];
558        let periods = CostPeriods::new_first(&PERIODS_ARRAY);
559
560        // January 15, 2025 is a Wednesday
561        let averages = vec![
562            // Matches high: winter weekday 10:00
563            PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500),
564            // Doesn't match high (hour 23): goes to low
565            PowerAverage::new(Stockholm.dt(2025, 1, 15, 23, 0, 0), 300),
566            // Matches high: winter weekday 14:00
567            PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400),
568        ];
569
570        let result = PowerTariffMatches::new(
571            TariffCalculationMethod::AverageHours(10),
572            None,
573            periods,
574            &averages
575                .iter()
576                .map(|a| a.into_virtual(None))
577                .collect::<Vec<_>>(),
578            None,
579        );
580
581        assert_eq!(result.items().len(), 2);
582
583        // High period gets 2 values (10:00, 14:00)
584        assert_eq!(result.items()[0].peaks().values().len(), 2);
585        assert_eq!(result.items()[0].peaks().values()[0].value, 500);
586        assert_eq!(result.items()[0].peaks().values()[1].value, 400);
587
588        // Low period gets 1 value (23:00)
589        assert_eq!(result.items()[1].peaks().values().len(), 1);
590        assert_eq!(result.items()[1].peaks().values()[0].value, 300);
591    }
592
593    #[test]
594    fn peak_periods_all_matching_duplicates_values() {
595        static PERIODS_ARRAY: [CostPeriod; 2] = [
596            CostPeriod::builder()
597                .load(LoadType::High)
598                .fixed_cost(10, 0)
599                .hours(6, 22)
600                .months(Month::November, Month::March)
601                .exclude_weekends()
602                .exclude_holidays(Country::SE)
603                .build(),
604            CostPeriod::builder()
605                .load(LoadType::Low)
606                .fixed_cost(5, 0)
607                .build(),
608        ];
609        let periods = CostPeriods::new_all(&PERIODS_ARRAY);
610
611        // January 15, 2025 is a Wednesday
612        let averages = vec![
613            PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500),
614            PowerAverage::new(Stockholm.dt(2025, 1, 15, 23, 0, 0), 300),
615        ];
616
617        let result = PowerTariffMatches::new(
618            TariffCalculationMethod::AverageHours(10),
619            None,
620            periods,
621            &averages
622                .iter()
623                .map(|a| a.into_virtual(None))
624                .collect::<Vec<_>>(),
625            None,
626        );
627
628        assert_eq!(result.items().len(), 2);
629
630        // High period gets 1 value (only 10:00 matches criteria)
631        assert_eq!(result.items()[0].peaks().values().len(), 1);
632        assert_eq!(result.items()[0].peaks().values()[0].value, 500);
633
634        // Low period gets both (no restrictions)
635        assert_eq!(result.items()[1].peaks().values().len(), 2);
636        assert_eq!(result.items()[1].peaks().values()[0].value, 500);
637        assert_eq!(result.items()[1].peaks().values()[1].value, 300);
638    }
639
640    #[test]
641    fn peak_periods_empty_averages() {
642        static PERIODS_ARRAY: [CostPeriod; 1] = [CostPeriod::builder()
643            .load(LoadType::Low)
644            .fixed_cost(5, 0)
645            .build()];
646        let periods = CostPeriods::new_first(&PERIODS_ARRAY);
647        let averages = vec![];
648
649        let result = PowerTariffMatches::new(
650            TariffCalculationMethod::AverageHours(3),
651            None,
652            periods,
653            &averages,
654            None,
655        );
656
657        assert_eq!(result.items().len(), 1);
658        assert_eq!(result.items()[0].peaks().values().len(), 0);
659    }
660
661    #[test]
662    fn single_value_both_methods() {
663        let averages = vec![PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100)];
664
665        let hours = PowerPeaks::new(
666            TariffCalculationMethod::AverageHours(3),
667            &averages
668                .iter()
669                .map(|a| a.into_virtual(None))
670                .collect::<Vec<_>>(),
671        );
672        assert_eq!(hours.values().len(), 1);
673        assert_eq!(hours.values()[0].value, 100);
674
675        let days = PowerPeaks::new(
676            TariffCalculationMethod::AverageDays(3),
677            &averages
678                .iter()
679                .map(|a| a.into_virtual(None))
680                .collect::<Vec<_>>(),
681        );
682        assert_eq!(days.values().len(), 1);
683        assert_eq!(days.values()[0].value, 100);
684    }
685
686    #[test]
687    fn covers_percentage_complete_overlap() {
688        // Partial average covers 10:00-11:00 (3600 seconds)
689        let partial = PartialPowerAverage::new(
690            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
691            3600,
692        );
693
694        // Query range is completely within the partial average range
695        let ts = Stockholm.dt(2025, 1, 1, 10, 15, 0).with_timezone(&Utc); // 10:15
696        let result = partial.cover_percentage(ts, 1800); // 30 minutes
697
698        assert_eq!(result, 100); // 100% overlap
699    }
700
701    #[test]
702    fn covers_percentage_partial_overlap_start() {
703        // Partial average covers 10:00-11:00
704        let partial = PartialPowerAverage::new(
705            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
706            3600,
707        );
708
709        // Query range starts before partial average, ends within it
710        // 09:30-10:30 overlaps with 10:00-11:00 at 10:00-10:30 (1800 seconds)
711        let ts = Stockholm.dt(2025, 1, 1, 9, 30, 0).with_timezone(&Utc);
712        let result = partial.cover_percentage(ts, 3600); // 60 minutes
713
714        assert_eq!(result, 50); // 1800/3600 = 50%
715    }
716
717    #[test]
718    fn covers_percentage_partial_overlap_end() {
719        // Partial average covers 10:00-11:00
720        let partial = PartialPowerAverage::new(
721            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
722            3600,
723        );
724
725        // Query range starts within partial average, ends after it
726        // 10:30-11:30 overlaps with 10:00-11:00 at 10:30-11:00 (1800 seconds)
727        let ts = Stockholm.dt(2025, 1, 1, 10, 30, 0).with_timezone(&Utc);
728        let result = partial.cover_percentage(ts, 3600); // 60 minutes
729
730        assert_eq!(result, 50); // 1800/3600 = 50%
731    }
732
733    #[test]
734    fn covers_percentage_no_overlap_before() {
735        // Partial average covers 10:00-11:00
736        let partial = PartialPowerAverage::new(
737            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
738            3600,
739        );
740
741        // Query range is completely before the partial average
742        let ts = Stockholm.dt(2025, 1, 1, 8, 0, 0).with_timezone(&Utc);
743        let result = partial.cover_percentage(ts, 3600);
744
745        assert_eq!(result, 0);
746    }
747
748    #[test]
749    fn covers_percentage_no_overlap_after() {
750        // Partial average covers 10:00-11:00
751        let partial = PartialPowerAverage::new(
752            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
753            3600,
754        );
755
756        // Query range is completely after the partial average
757        let ts = Stockholm.dt(2025, 1, 1, 12, 0, 0).with_timezone(&Utc);
758        let result = partial.cover_percentage(ts, 3600);
759
760        assert_eq!(result, 0);
761    }
762
763    #[test]
764    fn covers_percentage_no_overlap() {
765        // Partial average covers 10:00-11:00 Stockholm (09:00-10:00 UTC)
766        let partial = PartialPowerAverage::new(
767            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
768            3600,
769        );
770
771        // Query is 09:00-10:00 Stockholm (08:00-09:00 UTC)
772        // This overlaps with 09:00-10:00 UTC by 0 seconds
773        let ts = Stockholm.dt(2025, 1, 1, 9, 0, 0).with_timezone(&Utc);
774        let result = partial.cover_percentage(ts, 3600);
775
776        assert_eq!(result, 0);
777    }
778
779    #[test]
780    fn covers_percentage_half_overlap() {
781        // Partial average covers 10:00-11:00
782        let partial = PartialPowerAverage::new(
783            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
784            3600,
785        );
786
787        // Query overlaps by 1800 seconds (30 minutes) out of 3600 (60 minutes) = 50%
788        let ts = Stockholm.dt(2025, 1, 1, 9, 30, 0).with_timezone(&Utc);
789        let result = partial.cover_percentage(ts, 3600);
790
791        assert_eq!(result, 50);
792    }
793
794    #[test]
795    fn covers_percentage_query_contains_partial() {
796        // Partial average covers 10:00-11:00 (3600 seconds)
797        let partial = PartialPowerAverage::new(
798            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
799            3600,
800        );
801
802        // Query range completely contains the partial average
803        // 09:00-12:00 (10800 seconds) contains 10:00-11:00
804        let ts = Stockholm.dt(2025, 1, 1, 9, 0, 0).with_timezone(&Utc);
805        let result = partial.cover_percentage(ts, 10800);
806
807        assert_eq!(result, 33); // 3600/10800 = 33.33% -> 33 as u8
808    }
809
810    #[test]
811    fn covers_percentage_exact_match() {
812        // Partial average covers 10:00-11:00
813        let partial = PartialPowerAverage::new(
814            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
815            3600,
816        );
817
818        // Query range exactly matches the partial average range
819        let ts = Stockholm.dt(2025, 1, 1, 10, 0, 0).with_timezone(&Utc);
820        let result = partial.cover_percentage(ts, 3600);
821
822        assert_eq!(result, 100);
823    }
824
825    #[test]
826    fn covers_percentage_adjacent_ranges_no_overlap() {
827        // Partial average covers 10:00-11:00
828        let partial = PartialPowerAverage::new(
829            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
830            3600,
831        );
832
833        // Query range starts exactly when partial average ends
834        let ts = Stockholm.dt(2025, 1, 1, 11, 0, 0).with_timezone(&Utc);
835        let result = partial.cover_percentage(ts, 3600);
836
837        assert_eq!(result, 0); // Adjacent but not overlapping
838    }
839
840    #[test]
841    fn covers_percentage_very_small_overlap() {
842        // Partial average covers 10:00-11:00
843        let partial = PartialPowerAverage::new(
844            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
845            3600,
846        );
847
848        // Query overlaps by just 1 second
849        let ts = Stockholm.dt(2025, 1, 1, 10, 59, 59).with_timezone(&Utc);
850        let result = partial.cover_percentage(ts, 3600);
851
852        // 1 second out of 3600 = 0.027% -> 0 as u8
853        assert_eq!(result, 0);
854    }
855
856    #[test]
857    fn covers_percentage_short_duration() {
858        // Partial average covers 10:00:00-10:01:00 (60 seconds)
859        let partial = PartialPowerAverage::new(
860            PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 100),
861            60,
862        );
863
864        // Query overlaps by 30 seconds
865        let ts = Stockholm.dt(2025, 1, 1, 10, 0, 30).with_timezone(&Utc);
866        let result = partial.cover_percentage(ts, 60);
867
868        assert_eq!(result, 50); // 30/60 = 50%
869    }
870
871    mod map_periods_to_data_tests {
872        use super::*;
873        use crate::{LoadType, Stockholm, months::Month};
874
875        #[test]
876        fn map_periods_first_matching_no_overlap() {
877            // Create periods where averages match different periods
878            static PERIODS_ARRAY: [CostPeriod; 2] = [
879                CostPeriod::builder()
880                    .load(LoadType::High)
881                    .fixed_cost(10, 0)
882                    .hours(6, 12)
883                    .build(),
884                CostPeriod::builder()
885                    .load(LoadType::Low)
886                    .fixed_cost(5, 0)
887                    .hours(12, 22)
888                    .build(),
889            ];
890            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
891
892            let averages = vec![
893                PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500), // High period
894                PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400), // Low period
895            ];
896
897            let result = PeriodPeakMatches::new(
898                TariffCalculationMethod::AverageHours(10),
899                &periods,
900                &averages
901                    .iter()
902                    .map(|a| a.into_virtual(None))
903                    .collect::<Vec<_>>(),
904                CostPeriodMatching::First,
905            );
906
907            assert_eq!(result.len(), 2);
908            assert_eq!(result[0].peaks().values().len(), 1);
909            assert_eq!(result[0].peaks().values()[0].value, 500);
910            assert_eq!(result[1].peaks().values().len(), 1);
911            assert_eq!(result[1].peaks().values()[0].value, 400);
912        }
913
914        #[test]
915        fn map_periods_first_matching_overlapping_periods() {
916            // Create overlapping periods where First matching matters
917            static PERIODS_ARRAY: [CostPeriod; 2] = [
918                CostPeriod::builder()
919                    .load(LoadType::High)
920                    .fixed_cost(10, 0)
921                    .hours(6, 18) // 6-18
922                    .build(),
923                CostPeriod::builder()
924                    .load(LoadType::Low)
925                    .fixed_cost(5, 0)
926                    .hours(12, 22) // 12-22, overlaps with high
927                    .build(),
928            ];
929            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
930
931            let averages = vec![
932                PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500), // Only high
933                PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400), // Both match, goes to high (first)
934                PowerAverage::new(Stockholm.dt(2025, 1, 15, 20, 0, 0), 300), // Only low
935            ];
936
937            let result = PeriodPeakMatches::new(
938                TariffCalculationMethod::AverageHours(10),
939                &periods,
940                &averages
941                    .iter()
942                    .map(|a| a.into_virtual(None))
943                    .collect::<Vec<_>>(),
944                CostPeriodMatching::First,
945            );
946
947            assert_eq!(result.len(), 2);
948            // High gets first two (10:00 and 14:00)
949            assert_eq!(result[0].peaks().values().len(), 2);
950            assert_eq!(result[0].peaks().values()[0].value, 500);
951            assert_eq!(result[0].peaks().values()[1].value, 400);
952            // Low gets only the last one (20:00)
953            assert_eq!(result[1].peaks().values().len(), 1);
954            assert_eq!(result[1].peaks().values()[0].value, 300);
955        }
956
957        #[test]
958        fn map_periods_all_matching_duplicates() {
959            // Create overlapping periods where All matching allows duplicates
960            static PERIODS_ARRAY: [CostPeriod; 2] = [
961                CostPeriod::builder()
962                    .load(LoadType::High)
963                    .fixed_cost(10, 0)
964                    .hours(6, 18)
965                    .build(),
966                CostPeriod::builder()
967                    .load(LoadType::Base)
968                    .fixed_cost(5, 0)
969                    .build(),
970            ];
971            let periods = CostPeriods::new_all(&PERIODS_ARRAY);
972
973            let averages = vec![
974                PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400), // Matches both
975            ];
976
977            let result = PeriodPeakMatches::new(
978                TariffCalculationMethod::AverageHours(10),
979                &periods,
980                &averages
981                    .iter()
982                    .map(|a| a.into_virtual(None))
983                    .collect::<Vec<_>>(),
984                CostPeriodMatching::All,
985            );
986
987            assert_eq!(result.len(), 2);
988            // Both periods get the same average
989            assert_eq!(result[0].peaks().values().len(), 1);
990            assert_eq!(result[0].peaks().values()[0].value, 400);
991            assert_eq!(result[1].peaks().values().len(), 1);
992            assert_eq!(result[1].peaks().values()[0].value, 400);
993        }
994
995        #[test]
996        fn map_periods_empty_averages() {
997            static PERIODS_ARRAY: [CostPeriod; 1] = [CostPeriod::builder()
998                .load(LoadType::Low)
999                .fixed_cost(5, 0)
1000                .build()];
1001            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
1002            let averages = vec![];
1003
1004            let result = PeriodPeakMatches::new(
1005                TariffCalculationMethod::AverageHours(3),
1006                &periods,
1007                &averages,
1008                CostPeriodMatching::First,
1009            );
1010
1011            assert_eq!(result.len(), 1);
1012            assert_eq!(result[0].peaks().values().len(), 0);
1013        }
1014
1015        #[test]
1016        fn map_periods_no_matching_averages() {
1017            // Create a period that doesn't match any averages
1018            static PERIODS_ARRAY: [CostPeriod; 1] = [CostPeriod::builder()
1019                .load(LoadType::High)
1020                .fixed_cost(10, 0)
1021                .hours(6, 12)
1022                .months(Month::June, Month::August) // Summer only
1023                .build()];
1024            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
1025
1026            // January averages don't match summer period
1027            let averages = vec![PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500)];
1028
1029            let result = PeriodPeakMatches::new(
1030                TariffCalculationMethod::AverageHours(10),
1031                &periods,
1032                &averages
1033                    .iter()
1034                    .map(|a| a.into_virtual(None))
1035                    .collect::<Vec<_>>(),
1036                CostPeriodMatching::First,
1037            );
1038
1039            assert_eq!(result.len(), 1);
1040            assert_eq!(result[0].peaks().values().len(), 0);
1041        }
1042
1043        #[test]
1044        fn map_periods_preserves_calculation_method() {
1045            static PERIODS_ARRAY: [CostPeriod; 1] = [CostPeriod::builder()
1046                .load(LoadType::High)
1047                .fixed_cost(10, 0)
1048                .build()];
1049            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
1050
1051            // Create multiple averages on same day and different days
1052            let averages = vec![
1053                PowerAverage::new(Stockholm.dt(2025, 1, 1, 10, 0, 0), 500),
1054                PowerAverage::new(Stockholm.dt(2025, 1, 1, 14, 0, 0), 600),
1055                PowerAverage::new(Stockholm.dt(2025, 1, 2, 10, 0, 0), 300),
1056                PowerAverage::new(Stockholm.dt(2025, 1, 2, 14, 0, 0), 400),
1057            ];
1058
1059            // Test with AverageDays(1) - should get 1 peak (highest from top day)
1060            let result_days = PeriodPeakMatches::new(
1061                TariffCalculationMethod::AverageDays(1),
1062                &periods,
1063                &averages
1064                    .iter()
1065                    .map(|a| a.into_virtual(None))
1066                    .collect::<Vec<_>>(),
1067                CostPeriodMatching::First,
1068            );
1069            assert_eq!(result_days[0].peaks().values().len(), 1);
1070            assert_eq!(result_days[0].peaks().values()[0].value, 600); // Highest from day 1
1071
1072            // Test with AverageHours(2) - should get 2 peaks (2 highest hours)
1073            let result_hours = PeriodPeakMatches::new(
1074                TariffCalculationMethod::AverageHours(2),
1075                &periods,
1076                &averages
1077                    .iter()
1078                    .map(|a| a.into_virtual(None))
1079                    .collect::<Vec<_>>(),
1080                CostPeriodMatching::First,
1081            );
1082            assert_eq!(result_hours[0].peaks().values().len(), 2);
1083            assert_eq!(result_hours[0].peaks().values()[0].value, 600);
1084            assert_eq!(result_hours[0].peaks().values()[1].value, 500);
1085        }
1086
1087        #[test]
1088        fn map_periods_first_matching_multiple_periods() {
1089            // Test with 3 periods to ensure First matching works correctly across multiple
1090            static PERIODS_ARRAY: [CostPeriod; 3] = [
1091                CostPeriod::builder()
1092                    .load(LoadType::High)
1093                    .fixed_cost(10, 0)
1094                    .hours(6, 12)
1095                    .build(),
1096                CostPeriod::builder()
1097                    .load(LoadType::Base)
1098                    .fixed_cost(7, 50)
1099                    .hours(12, 18)
1100                    .build(),
1101                CostPeriod::builder()
1102                    .load(LoadType::Low)
1103                    .fixed_cost(5, 0)
1104                    .build(),
1105            ];
1106            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
1107
1108            let averages = vec![
1109                PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500), // High
1110                PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400), // Base
1111                PowerAverage::new(Stockholm.dt(2025, 1, 15, 20, 0, 0), 300), // Low
1112                PowerAverage::new(Stockholm.dt(2025, 1, 15, 3, 0, 0), 200),  // Low
1113            ];
1114
1115            let result = PeriodPeakMatches::new(
1116                TariffCalculationMethod::AverageHours(10),
1117                &periods,
1118                &averages
1119                    .iter()
1120                    .map(|a| a.into_virtual(None))
1121                    .collect::<Vec<_>>(),
1122                CostPeriodMatching::First,
1123            );
1124
1125            assert_eq!(result.len(), 3);
1126            assert_eq!(result[0].peaks().values().len(), 1); // High: 1 average
1127            assert_eq!(result[0].peaks().values()[0].value, 500);
1128            assert_eq!(result[1].peaks().values().len(), 1); // Base: 1 average
1129            assert_eq!(result[1].peaks().values()[0].value, 400);
1130            assert_eq!(result[2].peaks().values().len(), 2); // Low: 2 averages
1131            assert_eq!(result[2].peaks().values()[0].value, 300);
1132            assert_eq!(result[2].peaks().values()[1].value, 200);
1133        }
1134
1135        #[test]
1136        fn map_periods_all_matching_catch_all_period() {
1137            // Test All matching where one period catches everything
1138            static PERIODS_ARRAY: [CostPeriod; 2] = [
1139                CostPeriod::builder()
1140                    .load(LoadType::High)
1141                    .fixed_cost(10, 0)
1142                    .hours(6, 12)
1143                    .build(),
1144                CostPeriod::builder()
1145                    .load(LoadType::Low)
1146                    .fixed_cost(5, 0)
1147                    .build(), // Catch-all period
1148            ];
1149            let periods = CostPeriods::new_all(&PERIODS_ARRAY);
1150
1151            let averages = vec![
1152                PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500), // High + Low
1153                PowerAverage::new(Stockholm.dt(2025, 1, 15, 20, 0, 0), 300), // Low only
1154            ];
1155
1156            let result = PeriodPeakMatches::new(
1157                TariffCalculationMethod::AverageHours(10),
1158                &periods,
1159                &averages
1160                    .iter()
1161                    .map(|a| a.into_virtual(None))
1162                    .collect::<Vec<_>>(),
1163                CostPeriodMatching::All,
1164            );
1165
1166            assert_eq!(result.len(), 2);
1167            // High period gets one
1168            assert_eq!(result[0].peaks().values().len(), 1);
1169            assert_eq!(result[0].peaks().values()[0].value, 500);
1170            // Low period (catch-all) gets both
1171            assert_eq!(result[1].peaks().values().len(), 2);
1172            assert_eq!(result[1].peaks().values()[0].value, 500);
1173            assert_eq!(result[1].peaks().values()[1].value, 300);
1174        }
1175
1176        #[test]
1177        fn map_periods_order_preservation() {
1178            // Verify that the order of periods is preserved
1179            static PERIODS_ARRAY: [CostPeriod; 2] = [
1180                CostPeriod::builder()
1181                    .load(LoadType::High)
1182                    .fixed_cost(10, 0)
1183                    .hours(6, 12)
1184                    .build(),
1185                CostPeriod::builder()
1186                    .load(LoadType::Low)
1187                    .fixed_cost(5, 0)
1188                    .hours(12, 18)
1189                    .build(),
1190            ];
1191            let periods = CostPeriods::new_first(&PERIODS_ARRAY);
1192
1193            let averages = vec![
1194                PowerAverage::new(Stockholm.dt(2025, 1, 15, 10, 0, 0), 500),
1195                PowerAverage::new(Stockholm.dt(2025, 1, 15, 14, 0, 0), 400),
1196            ];
1197
1198            let result = PeriodPeakMatches::new(
1199                TariffCalculationMethod::AverageHours(10),
1200                &periods,
1201                &averages
1202                    .iter()
1203                    .map(|a| a.into_virtual(None))
1204                    .collect::<Vec<_>>(),
1205                CostPeriodMatching::First,
1206            );
1207
1208            assert_eq!(result.len(), 2);
1209            // Verify periods are in the same order by checking their peak values
1210            assert_eq!(result[0].peaks().values()[0].value, 500); // High period
1211            assert_eq!(result[1].peaks().values()[0].value, 400); // Low period
1212        }
1213    }
1214}