grid_tariffs/
peaks.rs

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