grid_tariffs/
costs.rs

1use std::slice::Iter;
2
3use chrono::{DateTime, Datelike};
4use chrono_tz::Tz;
5use serde::Serialize;
6
7use crate::{
8    Country, Language, Money,
9    defs::{Hours, Month, Months},
10    helpers,
11};
12
13#[derive(Debug, Clone, Copy, Serialize)]
14#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
15pub enum Cost {
16    None,
17    /// Cost has not been verified
18    Unverified,
19    Fixed(Money),
20    Fuses(&'static [(u16, Money)]),
21    /// Fuse size combined with a yearly energy consumption limit
22    FusesYearlyConsumption(&'static [(u16, Option<u32>, Money)]),
23    FuseRange(&'static [(u16, u16, Money)]),
24}
25
26impl Cost {
27    pub const fn is_unverified(&self) -> bool {
28        matches!(self, Self::Unverified)
29    }
30
31    pub(super) const fn fuses(values: &'static [(u16, Money)]) -> Self {
32        Self::Fuses(values)
33    }
34
35    pub(super) const fn fuse_range(ranges: &'static [(u16, u16, Money)]) -> Self {
36        Self::FuseRange(ranges)
37    }
38
39    pub(super) const fn fuses_with_yearly_consumption(
40        values: &'static [(u16, Option<u32>, Money)],
41    ) -> Cost {
42        Self::FusesYearlyConsumption(values)
43    }
44
45    pub(super) const fn fixed(int: i64, fract: u8) -> Self {
46        Self::Fixed(Money::new(int, fract))
47    }
48
49    pub(super) const fn fixed_yearly(int: i64, fract: u8) -> Self {
50        Self::Fixed(Money::new(int, fract).divide_by(12))
51    }
52
53    pub(super) const fn fixed_subunit(subunit: f64) -> Self {
54        Self::Fixed(Money::new_subunit(subunit))
55    }
56
57    pub(super) const fn divide_by(&self, by: i64) -> Self {
58        match self {
59            Self::None => Self::None,
60            Self::Unverified => Self::Unverified,
61            Self::Fixed(money) => Self::Fixed(money.divide_by(by)),
62            Self::Fuses(_) => panic!(".divide_by() is unsupported on Cost::Fuses"),
63            Self::FusesYearlyConsumption(_) => {
64                panic!(".divide_by() is unsupported on Cost::FuseRangeYearlyConsumption")
65            }
66            Self::FuseRange(_) => panic!(".divide_by() is unsupported on Cost::FuseRange"),
67        }
68    }
69
70    pub const fn cost_for(&self, fuse_size: u16, yearly_consumption: u32) -> Option<Money> {
71        match *self {
72            Cost::None => None,
73            Cost::Unverified => None,
74            Cost::Fixed(money) => Some(money),
75            Cost::Fuses(values) => {
76                let mut i = 0;
77                while i < values.len() {
78                    let (fsize, money) = values[i];
79                    if fuse_size == fsize {
80                        return Some(money);
81                    }
82                    i += 1;
83                }
84                None
85            }
86            Cost::FusesYearlyConsumption(values) => {
87                let mut i = 0;
88                while i < values.len() {
89                    let (fsize, max_consumption, money) = values[i];
90                    if fsize == fuse_size {
91                        if let Some(max_consumption) = max_consumption {
92                            if yearly_consumption <= max_consumption {
93                                return Some(money);
94                            }
95                        } else {
96                            return Some(money);
97                        }
98                    }
99                    i += 1;
100                }
101                None
102            }
103            Cost::FuseRange(ranges) => {
104                let mut i = 0;
105                while i < ranges.len() {
106                    let (min, max, money) = ranges[i];
107                    if fuse_size >= min && fuse_size <= max {
108                        return Some(money);
109                    }
110                    i += 1;
111                }
112                None
113            }
114        }
115    }
116
117    pub(crate) const fn add_vat(&self, country: Country) -> Cost {
118        match self {
119            Cost::None => Cost::None,
120            Cost::Unverified => Cost::Unverified,
121            Cost::Fixed(money) => Cost::Fixed(money.add_vat(country)),
122            Cost::Fuses(_) => todo!(),
123            Cost::FusesYearlyConsumption(_) => todo!(),
124            Cost::FuseRange(_) => todo!(),
125        }
126    }
127
128    pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
129        match self {
130            Cost::FusesYearlyConsumption(items) => items
131                .iter()
132                .filter(|(fsize, _, _)| *fsize == fuse_size)
133                .any(|(_, yearly_consumption, _)| yearly_consumption.is_some()),
134            _ => false,
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
140#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
141pub enum CostPeriodMatching {
142    First,
143    All,
144}
145
146#[derive(Debug, Clone, Copy, Serialize)]
147#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
148pub struct CostPeriods {
149    match_method: CostPeriodMatching,
150    periods: &'static [CostPeriod],
151}
152
153impl CostPeriods {
154    pub(super) const fn new_first(periods: &'static [CostPeriod]) -> Self {
155        Self {
156            match_method: CostPeriodMatching::First,
157            periods,
158        }
159    }
160
161    pub fn match_method(&self) -> CostPeriodMatching {
162        self.match_method
163    }
164
165    pub(super) const fn new_all(periods: &'static [CostPeriod]) -> Self {
166        Self {
167            match_method: CostPeriodMatching::All,
168            periods,
169        }
170    }
171
172    pub fn iter(&self) -> Iter<'_, CostPeriod> {
173        self.periods.iter()
174    }
175
176    pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
177        self.periods
178            .iter()
179            .any(|cp| cp.is_yearly_consumption_based(fuse_size))
180    }
181
182    pub fn matching_periods(&self, timestamp: DateTime<Tz>) -> Vec<&CostPeriod> {
183        let mut ret = vec![];
184        for period in self.periods {
185            if period.matches(timestamp) {
186                ret.push(period);
187                if self.match_method == CostPeriodMatching::First {
188                    break;
189                }
190            }
191        }
192        ret
193    }
194}
195
196/// Like CostPeriods, but with costs being simple Money objects
197#[derive(Debug, Clone, Serialize)]
198#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
199pub struct CostPeriodsSimple {
200    periods: Vec<CostPeriodSimple>,
201}
202
203impl CostPeriodsSimple {
204    pub(crate) fn new(
205        periods: CostPeriods,
206        fuse_size: u16,
207        yearly_consumption: u32,
208        language: Language,
209    ) -> Self {
210        Self {
211            periods: periods
212                .periods
213                .iter()
214                .flat_map(|period| {
215                    CostPeriodSimple::new(period, fuse_size, yearly_consumption, language)
216                })
217                .collect(),
218        }
219    }
220}
221
222#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
224pub struct CostPeriod {
225    cost: Cost,
226    load: LoadType,
227    #[serde(serialize_with = "helpers::skip_nones")]
228    include: [Option<Include>; 2],
229    #[serde(serialize_with = "helpers::skip_nones")]
230    exclude: [Option<Exclude>; 2],
231    /// Divide kw by this amount during this period
232    divide_kw_by: u8,
233}
234
235/// Like CostPeriod, but with cost being a simple Money object
236#[derive(Debug, Clone, Serialize)]
237#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
238pub(super) struct CostPeriodSimple {
239    cost: Money,
240    load: LoadType,
241    include: Vec<Include>,
242    exclude: Vec<Exclude>,
243    /// Divide kw by this amount during this period
244    divide_kw_by: u8,
245    info: String,
246}
247
248impl CostPeriodSimple {
249    fn new(
250        period: &CostPeriod,
251        fuse_size: u16,
252        yearly_consumption: u32,
253        language: Language,
254    ) -> Option<Self> {
255        let cost = period.cost().cost_for(fuse_size, yearly_consumption)?;
256        Some(
257            Self {
258                cost,
259                load: period.load,
260                include: period.include.into_iter().flatten().collect(),
261                exclude: period.exclude.into_iter().flatten().collect(),
262                divide_kw_by: period.divide_kw_by,
263                info: Default::default(),
264            }
265            .add_info(language),
266        )
267    }
268
269    fn add_info(mut self, language: Language) -> Self {
270        let mut infos = Vec::new();
271        for include in &self.include {
272            infos.push(include.translate(language));
273        }
274        for exclude in &self.exclude {
275            infos.push(exclude.translate(language).into());
276        }
277        self.info = infos.join(", ");
278        self
279    }
280}
281
282impl CostPeriod {
283    pub(super) const fn builder() -> CostPeriodBuilder {
284        CostPeriodBuilder::new()
285    }
286
287    pub const fn cost(&self) -> Cost {
288        self.cost
289    }
290
291    pub const fn load(&self) -> LoadType {
292        self.load
293    }
294
295    pub const fn power_multiplier(&self) -> f64 {
296        1. / self.divide_kw_by as f64
297    }
298
299    pub fn matches(&self, timestamp: DateTime<Tz>) -> bool {
300        for include in self.include_period_types() {
301            if !include.matches(timestamp) {
302                return false;
303            }
304        }
305
306        for exclude in self.exclude_period_types() {
307            if exclude.matches(timestamp) {
308                return false;
309            }
310        }
311        true
312    }
313
314    fn include_period_types(&self) -> Vec<Include> {
315        self.include.iter().flatten().copied().collect()
316    }
317
318    fn exclude_period_types(&self) -> Vec<Exclude> {
319        self.exclude.iter().flatten().copied().collect()
320    }
321
322    fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
323        self.cost.is_yearly_consumption_based(fuse_size)
324    }
325}
326
327#[derive(Debug, Clone, Copy, Serialize)]
328#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
329pub enum LoadType {
330    /// Base load. Always counts
331    Base,
332    /// Low load period. Commonly counts during night hours and the summer half of the year
333    Low,
334    /// High load period. Commonly counts during daytime hours and the winter half of the year
335    High,
336}
337
338pub(super) use LoadType::*;
339
340#[derive(Clone)]
341pub(super) struct CostPeriodBuilder {
342    cost: Cost,
343    load: Option<LoadType>,
344    include: [Option<Include>; 2],
345    exclude: [Option<Exclude>; 2],
346    /// Divide kw by this amount during this period
347    divide_kw_by: u8,
348}
349
350impl CostPeriodBuilder {
351    pub(super) const fn new() -> Self {
352        Self {
353            cost: Cost::None,
354            load: None,
355            include: [None; 2],
356            exclude: [None; 2],
357            divide_kw_by: 1,
358        }
359    }
360
361    pub(super) const fn build(self) -> CostPeriod {
362        CostPeriod {
363            cost: self.cost,
364            load: self.load.expect("`load` must be specified"),
365            include: self.include,
366            exclude: self.exclude,
367            divide_kw_by: self.divide_kw_by,
368        }
369    }
370
371    pub(super) const fn cost(mut self, cost: Cost) -> Self {
372        self.cost = cost;
373        self
374    }
375
376    pub(super) const fn load(mut self, load: LoadType) -> Self {
377        self.load = Some(load);
378        self
379    }
380
381    pub(super) const fn fixed_cost(mut self, int: i64, fract: u8) -> Self {
382        self.cost = Cost::fixed(int, fract);
383        self
384    }
385
386    pub(super) const fn fixed_cost_subunit(mut self, subunit: f64) -> Self {
387        self.cost = Cost::fixed_subunit(subunit);
388        self
389    }
390
391    pub(super) const fn include(mut self, period_type: Include) -> Self {
392        let mut i = 0;
393        while i < self.include.len() {
394            if self.include[i].is_some() {
395                i += 1;
396            } else {
397                self.include[i] = Some(period_type);
398                return self;
399            }
400        }
401        panic!("Too many includes");
402    }
403
404    pub(super) const fn months(self, from: Month, to: Month) -> Self {
405        self.include(Include::Months(Months::new(from, to)))
406    }
407
408    pub(super) const fn month(self, month: Month) -> Self {
409        self.include(Include::Month(month))
410    }
411
412    pub(super) const fn hours(self, from: u8, to_inclusive: u8) -> Self {
413        self.include(Include::Hours(Hours::new(from, to_inclusive)))
414    }
415
416    pub(super) const fn exclude(mut self, period_type: Exclude) -> Self {
417        let mut i = 0;
418        while i < self.exclude.len() {
419            if self.exclude[i].is_some() {
420                i += 1;
421            } else {
422                self.exclude[i] = Some(period_type);
423                return self;
424            }
425        }
426        panic!("Too many excludes");
427    }
428
429    pub(super) const fn exclude_holidays(self, country: Country) -> Self {
430        self.exclude(Exclude::Holidays(country))
431    }
432
433    pub(super) const fn exclude_weekends(self) -> Self {
434        self.exclude(Exclude::Weekends)
435    }
436}
437
438#[derive(Debug, Clone, Copy, Serialize)]
439#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
440pub(super) enum Include {
441    Months(Months),
442    Month(Month),
443    Hours(Hours),
444}
445
446impl Include {
447    fn translate(&self, language: Language) -> String {
448        match self {
449            Include::Months(months) => months.translate(language),
450            Include::Month(month) => month.translate(language).into(),
451            Include::Hours(hours) => hours.translate(language),
452        }
453    }
454
455    fn matches(&self, timestamp: DateTime<Tz>) -> bool {
456        match self {
457            Include::Months(months) => months.matches(timestamp),
458            Include::Month(month) => month.matches(timestamp),
459            Include::Hours(hours) => hours.matches(timestamp),
460        }
461    }
462}
463
464#[derive(Debug, Clone, Copy, Serialize)]
465#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
466pub(super) enum Exclude {
467    Weekends,
468    Holidays(Country),
469}
470
471impl Exclude {
472    pub(super) fn translate(&self, language: Language) -> &'static str {
473        match language {
474            Language::En => match self {
475                Exclude::Weekends => "Weekends",
476                Exclude::Holidays(country) => match country {
477                    Country::SE => "Swedish holidays",
478                },
479            },
480            Language::Sv => match self {
481                Exclude::Weekends => "Helg",
482                Exclude::Holidays(country) => match country {
483                    Country::SE => "Svenska helgdagar",
484                },
485            },
486        }
487    }
488
489    fn matches(&self, timestamp: DateTime<Tz>) -> bool {
490        match self {
491            Exclude::Weekends => (6..=7).contains(&timestamp.weekday().number_from_monday()),
492            Exclude::Holidays(country) => country.is_holiday(timestamp.date_naive()),
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::defs::{Hours, Month, Months};
501    use crate::money::Money;
502    use chrono::TimeZone;
503    use chrono_tz::Europe::Stockholm;
504
505    #[test]
506    fn cost_for_none() {
507        const NONE_COST: Cost = Cost::None;
508        assert_eq!(NONE_COST.cost_for(16, 0), None);
509        assert_eq!(NONE_COST.cost_for(25, 5000), None);
510    }
511
512    #[test]
513    fn cost_for_unverified() {
514        const UNVERIFIED_COST: Cost = Cost::Unverified;
515        assert_eq!(UNVERIFIED_COST.cost_for(16, 0), None);
516        assert_eq!(UNVERIFIED_COST.cost_for(25, 5000), None);
517    }
518
519    #[test]
520    fn cost_for_fixed() {
521        const FIXED_COST: Cost = Cost::Fixed(Money::new(100, 50));
522        // Fixed cost should return the same value regardless of fuse size or consumption
523        assert_eq!(FIXED_COST.cost_for(16, 0), Some(Money::new(100, 50)));
524        assert_eq!(FIXED_COST.cost_for(25, 5000), Some(Money::new(100, 50)));
525        assert_eq!(FIXED_COST.cost_for(63, 10000), Some(Money::new(100, 50)));
526    }
527
528    #[test]
529    fn cost_for_fuses_exact_match() {
530        const FUSES_COST: Cost = Cost::fuses(&[
531            (16, Money::new(50, 0)),
532            (25, Money::new(75, 0)),
533            (35, Money::new(100, 0)),
534            (50, Money::new(150, 0)),
535        ]);
536
537        // Test exact matches
538        assert_eq!(FUSES_COST.cost_for(16, 0), Some(Money::new(50, 0)));
539        assert_eq!(FUSES_COST.cost_for(25, 0), Some(Money::new(75, 0)));
540        assert_eq!(FUSES_COST.cost_for(35, 0), Some(Money::new(100, 0)));
541        assert_eq!(FUSES_COST.cost_for(50, 0), Some(Money::new(150, 0)));
542
543        // Yearly consumption should not affect the result
544        assert_eq!(FUSES_COST.cost_for(25, 500000), Some(Money::new(75, 0)));
545    }
546
547    #[test]
548    fn cost_for_fuses_no_match() {
549        const FUSES_COST: Cost = Cost::fuses(&[(16, Money::new(50, 0)), (25, Money::new(75, 0))]);
550
551        // Test non-matching fuse sizes
552        assert_eq!(FUSES_COST.cost_for(20, 0), None);
553        assert_eq!(FUSES_COST.cost_for(63, 0), None);
554    }
555
556    #[test]
557    fn cost_for_fuses_yearly_consumption_with_limit() {
558        const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
559            (16, Some(5000), Money::new(50, 0)),
560            (16, None, Money::new(75, 0)),
561            (25, Some(10000), Money::new(100, 0)),
562            (25, None, Money::new(125, 0)),
563        ]);
564
565        // 16A fuse with consumption below limit - matches the entry with the limit
566        assert_eq!(
567            FUSES_WITH_CONSUMPTION.cost_for(16, 3000),
568            Some(Money::new(50, 0))
569        );
570
571        // 16A fuse with consumption at limit - matches the entry with the limit
572        assert_eq!(
573            FUSES_WITH_CONSUMPTION.cost_for(16, 5000),
574            Some(Money::new(50, 0))
575        );
576
577        // 16A fuse with consumption above limit - falls through to entry with no limit
578        assert_eq!(
579            FUSES_WITH_CONSUMPTION.cost_for(16, 6000),
580            Some(Money::new(75, 0))
581        );
582
583        // 16A fuse with very high consumption - falls through to entry with no limit
584        assert_eq!(
585            FUSES_WITH_CONSUMPTION.cost_for(16, 20000),
586            Some(Money::new(75, 0))
587        );
588
589        // 25A fuse with consumption at limit - matches the entry with 10000 limit
590        assert_eq!(
591            FUSES_WITH_CONSUMPTION.cost_for(25, 10000),
592            Some(Money::new(100, 0))
593        );
594
595        // 25A fuse with consumption above limit - falls through to entry with no limit
596        assert_eq!(
597            FUSES_WITH_CONSUMPTION.cost_for(25, 15000),
598            Some(Money::new(125, 0))
599        );
600
601        // 25A fuse with consumption below limit - matches the entry with the limit
602        assert_eq!(
603            FUSES_WITH_CONSUMPTION.cost_for(25, 5000),
604            Some(Money::new(100, 0))
605        );
606    }
607
608    #[test]
609    fn cost_for_fuses_yearly_consumption_no_limit() {
610        const FUSES_NO_LIMIT: Cost = Cost::fuses_with_yearly_consumption(&[
611            (16, None, Money::new(50, 0)),
612            (25, None, Money::new(75, 0)),
613        ]);
614
615        // Should match regardless of consumption when limit is None
616        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 0), Some(Money::new(50, 0)));
617        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 1000), Some(Money::new(50, 0)));
618        assert_eq!(FUSES_NO_LIMIT.cost_for(16, 50000), Some(Money::new(50, 0)));
619        assert_eq!(FUSES_NO_LIMIT.cost_for(25, 100000), Some(Money::new(75, 0)));
620    }
621
622    #[test]
623    fn cost_for_fuses_yearly_consumption_no_fuse_match() {
624        const FUSES_WITH_CONSUMPTION: Cost = Cost::fuses_with_yearly_consumption(&[
625            (16, Some(5000), Money::new(50, 0)),
626            (25, Some(10000), Money::new(100, 0)),
627        ]);
628
629        // Non-matching fuse size
630        assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(35, 5000), None);
631        assert_eq!(FUSES_WITH_CONSUMPTION.cost_for(50, 10000), None);
632    }
633
634    #[test]
635    fn cost_for_fuses_yearly_consumption_max_limit_no_fallback() {
636        const FUSES_ONLY_LIMITS: Cost = Cost::fuses_with_yearly_consumption(&[
637            (16, Some(5000), Money::new(50, 0)),
638            (25, Some(10000), Money::new(100, 0)),
639        ]);
640
641        // Matching fuse size with consumption at or below limit - should match
642        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 0), Some(Money::new(50, 0)));
643        assert_eq!(
644            FUSES_ONLY_LIMITS.cost_for(16, 3000),
645            Some(Money::new(50, 0))
646        );
647        assert_eq!(
648            FUSES_ONLY_LIMITS.cost_for(16, 4999),
649            Some(Money::new(50, 0))
650        );
651        assert_eq!(
652            FUSES_ONLY_LIMITS.cost_for(16, 5000),
653            Some(Money::new(50, 0))
654        );
655        assert_eq!(
656            FUSES_ONLY_LIMITS.cost_for(25, 9999),
657            Some(Money::new(100, 0))
658        );
659        assert_eq!(
660            FUSES_ONLY_LIMITS.cost_for(25, 10000),
661            Some(Money::new(100, 0))
662        );
663
664        // Above limit with no fallback - should return None
665        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 5001), None);
666        assert_eq!(FUSES_ONLY_LIMITS.cost_for(16, 10000), None);
667        assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 10001), None);
668        assert_eq!(FUSES_ONLY_LIMITS.cost_for(25, 20000), None);
669    }
670
671    #[test]
672    fn cost_for_fuse_range_within_range() {
673        const FUSE_BASED: Cost = Cost::fuse_range(&[
674            (16, 35, Money::new(54, 0)),
675            (35, u16::MAX, Money::new(108, 50)),
676        ]);
677
678        // Test values below the first range
679        assert_eq!(FUSE_BASED.cost_for(10, 0), None);
680        assert_eq!(FUSE_BASED.cost_for(15, 0), None);
681
682        // Test values within the first range
683        assert_eq!(FUSE_BASED.cost_for(16, 0), Some(Money::new(54, 0)));
684        assert_eq!(FUSE_BASED.cost_for(25, 0), Some(Money::new(54, 0)));
685        assert_eq!(FUSE_BASED.cost_for(35, 0), Some(Money::new(54, 0)));
686
687        // Test values within the second range
688        assert_eq!(FUSE_BASED.cost_for(36, 0), Some(Money::new(108, 50)));
689        assert_eq!(FUSE_BASED.cost_for(50, 0), Some(Money::new(108, 50)));
690        assert_eq!(FUSE_BASED.cost_for(200, 0), Some(Money::new(108, 50)));
691        assert_eq!(FUSE_BASED.cost_for(u16::MAX, 0), Some(Money::new(108, 50)));
692    }
693
694    #[test]
695    fn cost_for_fuse_range_multiple_ranges() {
696        const MULTI_RANGE: Cost = Cost::fuse_range(&[
697            (1, 15, Money::new(20, 0)),
698            (16, 35, Money::new(50, 0)),
699            (36, 63, Money::new(100, 0)),
700            (64, u16::MAX, Money::new(200, 0)),
701        ]);
702
703        // Test each range
704        assert_eq!(MULTI_RANGE.cost_for(10, 0), Some(Money::new(20, 0)));
705        assert_eq!(MULTI_RANGE.cost_for(25, 0), Some(Money::new(50, 0)));
706        assert_eq!(MULTI_RANGE.cost_for(50, 0), Some(Money::new(100, 0)));
707        assert_eq!(MULTI_RANGE.cost_for(100, 0), Some(Money::new(200, 0)));
708
709        // Yearly consumption should not affect range-based costs
710        assert_eq!(MULTI_RANGE.cost_for(25, 10000), Some(Money::new(50, 0)));
711    }
712
713    #[test]
714    fn include_matches_hours() {
715        let include = Include::Hours(Hours::new(6, 22));
716        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
717        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
718
719        assert!(include.matches(timestamp_match));
720        assert!(!include.matches(timestamp_no_match));
721    }
722
723    #[test]
724    fn include_matches_month() {
725        let include = Include::Month(Month::June);
726        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
727        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
728
729        assert!(include.matches(timestamp_match));
730        assert!(!include.matches(timestamp_no_match));
731    }
732
733    #[test]
734    fn include_matches_months() {
735        let include = Include::Months(Months::new(Month::November, Month::March));
736        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
737        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
738
739        assert!(include.matches(timestamp_match));
740        assert!(!include.matches(timestamp_no_match));
741    }
742
743    #[test]
744    fn exclude_matches_weekends_saturday() {
745        let exclude = Exclude::Weekends;
746        // January 4, 2025 is a Saturday
747        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
748        assert!(exclude.matches(timestamp));
749    }
750
751    #[test]
752    fn exclude_matches_weekends_sunday() {
753        let exclude = Exclude::Weekends;
754        // January 5, 2025 is a Sunday
755        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
756        assert!(exclude.matches(timestamp));
757    }
758
759    #[test]
760    fn exclude_does_not_match_weekday() {
761        let exclude = Exclude::Weekends;
762        // January 6, 2025 is a Monday
763        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).unwrap();
764        assert!(!exclude.matches(timestamp));
765    }
766
767    #[test]
768    fn exclude_matches_swedish_new_year() {
769        let exclude = Exclude::Holidays(Country::SE);
770        // January 1 is a Swedish holiday
771        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
772        assert!(exclude.matches(timestamp));
773    }
774
775    #[test]
776    fn exclude_does_not_match_non_holiday() {
777        let exclude = Exclude::Holidays(Country::SE);
778        // January 2, 2025 is not a Swedish holiday
779        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 2, 12, 0, 0).unwrap();
780        assert!(!exclude.matches(timestamp));
781    }
782
783    #[test]
784    fn cost_period_matches_with_single_include() {
785        let period = CostPeriod::builder()
786            .load(LoadType::High)
787            .fixed_cost(10, 0)
788            .hours(6, 22)
789            .build();
790
791        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
792        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
793
794        assert!(period.matches(timestamp_match));
795        assert!(!period.matches(timestamp_no_match));
796    }
797
798    #[test]
799    fn cost_period_matches_with_multiple_includes() {
800        let period = CostPeriod::builder()
801            .load(LoadType::High)
802            .fixed_cost(10, 0)
803            .hours(6, 22)
804            .months(Month::November, Month::March)
805            .build();
806
807        // Winter daytime - should match
808        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
809        // Winter nighttime - should not match (wrong hours)
810        let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
811        // Summer daytime - should not match (wrong months)
812        let timestamp_wrong_months = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
813
814        assert!(period.matches(timestamp_match));
815        assert!(!period.matches(timestamp_wrong_hours));
816        assert!(!period.matches(timestamp_wrong_months));
817    }
818
819    #[test]
820    fn cost_period_matches_with_exclude_weekends() {
821        let period = CostPeriod::builder()
822            .load(LoadType::High)
823            .fixed_cost(10, 0)
824            .hours(6, 22)
825            .exclude_weekends()
826            .build();
827
828        println!("Excludes: {:?}", period.exclude_period_types());
829        println!("Includes: {:?}", period.include_period_types());
830
831        // Monday daytime - should match
832        let timestamp_weekday = Stockholm.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap();
833        // Saturday daytime - should not match (excluded)
834        let timestamp_saturday = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
835
836        assert!(period.matches(timestamp_weekday));
837        assert!(!period.matches(timestamp_saturday));
838    }
839
840    #[test]
841    fn cost_period_matches_with_exclude_holidays() {
842        let period = CostPeriod::builder()
843            .load(LoadType::High)
844            .fixed_cost(10, 0)
845            .hours(6, 22)
846            .exclude_holidays(Country::SE)
847            .build();
848
849        // Regular weekday - should match
850        let timestamp_regular = Stockholm.with_ymd_and_hms(2025, 1, 2, 14, 0, 0).unwrap();
851        // New Year's Day - should not match (excluded)
852        let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
853
854        assert!(period.matches(timestamp_regular));
855        assert!(!period.matches(timestamp_holiday));
856    }
857
858    #[test]
859    fn cost_period_matches_complex_scenario() {
860        // Winter high load period: Nov-Mar, 6-22, excluding weekends and holidays
861        let period = CostPeriod::builder()
862            .load(LoadType::High)
863            .fixed_cost(10, 0)
864            .months(Month::November, Month::March)
865            .hours(6, 22)
866            .exclude_weekends()
867            .exclude_holidays(Country::SE)
868            .build();
869
870        // Winter weekday daytime (not holiday) - should match
871        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
872
873        // Winter weekday nighttime - should not match (wrong hours)
874        let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
875
876        // Winter Saturday daytime - should not match (weekend)
877        let timestamp_weekend = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
878
879        // New Year's Day (holiday) - should not match
880        let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
881
882        // Summer weekday daytime - should not match (wrong months)
883        let timestamp_summer = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
884
885        assert!(period.matches(timestamp_match));
886        assert!(!period.matches(timestamp_wrong_hours));
887        assert!(!period.matches(timestamp_weekend));
888        assert!(!period.matches(timestamp_holiday));
889        assert!(!period.matches(timestamp_summer));
890    }
891
892    #[test]
893    fn cost_period_matches_base_load() {
894        // Base load period with no restrictions
895        let period = CostPeriod::builder()
896            .load(LoadType::Base)
897            .fixed_cost(5, 0)
898            .build();
899
900        // Should match any time
901        let timestamp1 = Stockholm.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
902        let timestamp2 = Stockholm.with_ymd_and_hms(2025, 7, 15, 23, 59, 59).unwrap();
903        let timestamp3 = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
904
905        assert!(period.matches(timestamp1));
906        assert!(period.matches(timestamp2));
907        assert!(period.matches(timestamp3));
908    }
909
910    #[test]
911    fn include_matches_hours_wraparound() {
912        // Night hours crossing midnight: 22:00 to 05:59
913        let include = Include::Hours(Hours::new(22, 5));
914
915        // Should match late evening
916        let timestamp_evening = Stockholm.with_ymd_and_hms(2025, 1, 15, 22, 0, 0).unwrap();
917        assert!(include.matches(timestamp_evening));
918
919        // Should match midnight
920        let timestamp_midnight = Stockholm.with_ymd_and_hms(2025, 1, 15, 0, 0, 0).unwrap();
921        assert!(include.matches(timestamp_midnight));
922
923        // Should match early morning
924        let timestamp_morning = Stockholm.with_ymd_and_hms(2025, 1, 15, 5, 30, 0).unwrap();
925        assert!(include.matches(timestamp_morning));
926
927        // Should not match daytime
928        let timestamp_day = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
929        assert!(!include.matches(timestamp_day));
930
931        // Should not match just after the range
932        let timestamp_after = Stockholm.with_ymd_and_hms(2025, 1, 15, 6, 0, 0).unwrap();
933        assert!(!include.matches(timestamp_after));
934
935        // Should not match just before the range
936        let timestamp_before = Stockholm.with_ymd_and_hms(2025, 1, 15, 21, 59, 59).unwrap();
937        assert!(!include.matches(timestamp_before));
938    }
939
940    #[test]
941    fn include_matches_months_wraparound() {
942        // Winter months crossing year boundary: November to March
943        let include = Include::Months(Months::new(Month::November, Month::March));
944
945        // Should match November (start)
946        let timestamp_nov = Stockholm.with_ymd_and_hms(2025, 11, 15, 12, 0, 0).unwrap();
947        assert!(include.matches(timestamp_nov));
948
949        // Should match December
950        let timestamp_dec = Stockholm.with_ymd_and_hms(2025, 12, 15, 12, 0, 0).unwrap();
951        assert!(include.matches(timestamp_dec));
952
953        // Should match January
954        let timestamp_jan = Stockholm.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
955        assert!(include.matches(timestamp_jan));
956
957        // Should match March (end)
958        let timestamp_mar = Stockholm.with_ymd_and_hms(2025, 3, 15, 12, 0, 0).unwrap();
959        assert!(include.matches(timestamp_mar));
960
961        // Should not match summer months
962        let timestamp_jul = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
963        assert!(!include.matches(timestamp_jul));
964
965        // Should not match October (just before)
966        let timestamp_oct = Stockholm
967            .with_ymd_and_hms(2025, 10, 31, 23, 59, 59)
968            .unwrap();
969        assert!(!include.matches(timestamp_oct));
970
971        // Should not match April (just after)
972        let timestamp_apr = Stockholm.with_ymd_and_hms(2025, 4, 1, 0, 0, 0).unwrap();
973        assert!(!include.matches(timestamp_apr));
974    }
975
976    #[test]
977    fn cost_period_matches_hours_wraparound() {
978        // Night period: 22:00 to 05:59
979        let period = CostPeriod::builder()
980            .load(LoadType::Low)
981            .fixed_cost(5, 0)
982            .hours(22, 5)
983            .build();
984
985        let timestamp_match_evening = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
986        let timestamp_match_morning = Stockholm.with_ymd_and_hms(2025, 1, 15, 3, 0, 0).unwrap();
987        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
988
989        assert!(period.matches(timestamp_match_evening));
990        assert!(period.matches(timestamp_match_morning));
991        assert!(!period.matches(timestamp_no_match));
992    }
993
994    #[test]
995    fn cost_period_matches_with_both_excludes() {
996        let period = CostPeriod::builder()
997            .load(LoadType::High)
998            .fixed_cost(10, 0)
999            .hours(6, 22)
1000            .exclude_weekends()
1001            .exclude_holidays(Country::SE)
1002            .build();
1003
1004        // Regular weekday - should match
1005        let weekday = Stockholm.with_ymd_and_hms(2025, 1, 2, 14, 0, 0).unwrap();
1006        assert!(period.matches(weekday));
1007
1008        // Weekend - should not match
1009        let saturday = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
1010        assert!(!period.matches(saturday));
1011
1012        // Holiday (New Year) - should not match
1013        let holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
1014        assert!(!period.matches(holiday));
1015
1016        // Weekday but wrong hours - should not match
1017        let wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 2, 23, 0, 0).unwrap();
1018        assert!(!period.matches(wrong_hours));
1019    }
1020
1021    #[test]
1022    fn exclude_matches_friday_is_not_weekend() {
1023        let exclude = Exclude::Weekends;
1024        // January 3, 2025 is a Friday
1025        let friday = Stockholm.with_ymd_and_hms(2025, 1, 3, 12, 0, 0).unwrap();
1026        assert!(!exclude.matches(friday));
1027    }
1028
1029    #[test]
1030    fn exclude_matches_monday_is_not_weekend() {
1031        let exclude = Exclude::Weekends;
1032        // January 6, 2025 is a Monday
1033        let monday = Stockholm.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).unwrap();
1034        assert!(!exclude.matches(monday));
1035    }
1036
1037    #[test]
1038    fn exclude_matches_holiday_midsummer() {
1039        let exclude = Exclude::Holidays(Country::SE);
1040        // Midsummer 2025 (June 21)
1041        let midsummer = Stockholm.with_ymd_and_hms(2025, 6, 21, 12, 0, 0).unwrap();
1042        assert!(exclude.matches(midsummer));
1043    }
1044
1045    #[test]
1046    fn cost_period_matches_month_and_hours() {
1047        // June with specific hours
1048        let period = CostPeriod::builder()
1049            .load(LoadType::Low)
1050            .fixed_cost(5, 0)
1051            .month(Month::June)
1052            .hours(22, 5)
1053            .build();
1054
1055        // June during night hours - should match
1056        let match_june_night = Stockholm.with_ymd_and_hms(2025, 6, 15, 23, 0, 0).unwrap();
1057        assert!(period.matches(match_june_night));
1058
1059        // June during day hours - should not match
1060        let june_day = Stockholm.with_ymd_and_hms(2025, 6, 15, 14, 0, 0).unwrap();
1061        assert!(!period.matches(june_day));
1062
1063        // July during night hours - should not match (wrong month)
1064        let july_night = Stockholm.with_ymd_and_hms(2025, 7, 15, 23, 0, 0).unwrap();
1065        assert!(!period.matches(july_night));
1066    }
1067
1068    #[test]
1069    fn cost_period_matches_months_and_hours_with_exclude() {
1070        // Winter high load: Nov-Mar, 6-22, excluding weekends and holidays
1071        let period = CostPeriod::builder()
1072            .load(LoadType::High)
1073            .fixed_cost(15, 0)
1074            .months(Month::November, Month::March)
1075            .hours(6, 22)
1076            .exclude_weekends()
1077            .exclude_holidays(Country::SE)
1078            .build();
1079
1080        // Perfect match: winter weekday during day hours
1081        let perfect = Stockholm.with_ymd_and_hms(2025, 1, 15, 10, 0, 0).unwrap();
1082        assert!(period.matches(perfect));
1083
1084        // First hour of range
1085        let first_hour = Stockholm.with_ymd_and_hms(2025, 1, 15, 6, 0, 0).unwrap();
1086        assert!(period.matches(first_hour));
1087
1088        // Last hour of range
1089        let last_hour = Stockholm.with_ymd_and_hms(2025, 1, 15, 22, 59, 59).unwrap();
1090        assert!(period.matches(last_hour));
1091
1092        // Wrong hours (too early)
1093        let too_early = Stockholm.with_ymd_and_hms(2025, 1, 15, 5, 59, 59).unwrap();
1094        assert!(!period.matches(too_early));
1095
1096        // Wrong hours (too late)
1097        let too_late = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
1098        assert!(!period.matches(too_late));
1099
1100        // Wrong month (summer)
1101        let summer = Stockholm.with_ymd_and_hms(2025, 7, 15, 10, 0, 0).unwrap();
1102        assert!(!period.matches(summer));
1103
1104        // Weekend
1105        let weekend = Stockholm.with_ymd_and_hms(2025, 1, 4, 10, 0, 0).unwrap();
1106        assert!(!period.matches(weekend));
1107    }
1108
1109    #[test]
1110    fn cost_period_matches_base_with_restrictions() {
1111        // Base load but with hour restrictions
1112        let period = CostPeriod::builder()
1113            .load(LoadType::Base)
1114            .fixed_cost(3, 0)
1115            .hours(0, 5)
1116            .build();
1117
1118        // Should match only during specified hours
1119        let match_night = Stockholm.with_ymd_and_hms(2025, 1, 15, 3, 0, 0).unwrap();
1120        assert!(period.matches(match_night));
1121
1122        // Should not match outside hours
1123        let no_match_day = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
1124        assert!(!period.matches(no_match_day));
1125    }
1126
1127    #[test]
1128    fn cost_period_matches_single_month() {
1129        let period = CostPeriod::builder()
1130            .load(LoadType::High)
1131            .fixed_cost(10, 0)
1132            .month(Month::December)
1133            .build();
1134
1135        // First day of December
1136        let dec_first = Stockholm.with_ymd_and_hms(2025, 12, 1, 0, 0, 0).unwrap();
1137        assert!(period.matches(dec_first));
1138
1139        // Last day of December
1140        let dec_last = Stockholm
1141            .with_ymd_and_hms(2025, 12, 31, 23, 59, 59)
1142            .unwrap();
1143        assert!(period.matches(dec_last));
1144
1145        // November - should not match
1146        let nov = Stockholm.with_ymd_and_hms(2025, 11, 30, 12, 0, 0).unwrap();
1147        assert!(!period.matches(nov));
1148
1149        // January - should not match
1150        let jan = Stockholm.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
1151        assert!(!period.matches(jan));
1152    }
1153
1154    #[test]
1155    fn cost_period_matches_all_hours() {
1156        // Full day coverage: 0-23
1157        let period = CostPeriod::builder()
1158            .load(LoadType::Low)
1159            .fixed_cost(5, 0)
1160            .hours(0, 23)
1161            .build();
1162
1163        let midnight = Stockholm.with_ymd_and_hms(2025, 1, 15, 0, 0, 0).unwrap();
1164        let noon = Stockholm.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
1165        let almost_midnight = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 59, 59).unwrap();
1166
1167        assert!(period.matches(midnight));
1168        assert!(period.matches(noon));
1169        assert!(period.matches(almost_midnight));
1170    }
1171
1172    #[test]
1173    fn cost_period_matches_edge_of_month_range() {
1174        // May to September
1175        let period = CostPeriod::builder()
1176            .load(LoadType::Low)
1177            .fixed_cost(5, 0)
1178            .months(Month::May, Month::September)
1179            .build();
1180
1181        // First second of May
1182        let may_start = Stockholm.with_ymd_and_hms(2025, 5, 1, 0, 0, 0).unwrap();
1183        assert!(period.matches(may_start));
1184
1185        // Last second of April - should not match
1186        let april_end = Stockholm.with_ymd_and_hms(2025, 4, 30, 23, 59, 59).unwrap();
1187        assert!(!period.matches(april_end));
1188
1189        // Last second of September
1190        let sept_end = Stockholm.with_ymd_and_hms(2025, 9, 30, 23, 59, 59).unwrap();
1191        assert!(period.matches(sept_end));
1192
1193        // First second of October - should not match
1194        let oct_start = Stockholm.with_ymd_and_hms(2025, 10, 1, 0, 0, 0).unwrap();
1195        assert!(!period.matches(oct_start));
1196    }
1197
1198    #[test]
1199    fn include_matches_month_boundary() {
1200        // Test first and last day of specific month
1201        let include = Include::Month(Month::February);
1202
1203        // First second of February
1204        let feb_start = Stockholm.with_ymd_and_hms(2025, 2, 1, 0, 0, 0).unwrap();
1205        assert!(include.matches(feb_start));
1206
1207        // Last second of February
1208        let feb_end = Stockholm.with_ymd_and_hms(2025, 2, 28, 23, 59, 59).unwrap();
1209        assert!(include.matches(feb_end));
1210
1211        // Last second of January
1212        let jan_end = Stockholm.with_ymd_and_hms(2025, 1, 31, 23, 59, 59).unwrap();
1213        assert!(!include.matches(jan_end));
1214
1215        // First second of March
1216        let mar_start = Stockholm.with_ymd_and_hms(2025, 3, 1, 0, 0, 0).unwrap();
1217        assert!(!include.matches(mar_start));
1218    }
1219
1220    #[test]
1221    fn include_matches_hours_exact_boundaries() {
1222        let include = Include::Hours(Hours::new(6, 22));
1223
1224        // First second of hour 6
1225        let start = Stockholm.with_ymd_and_hms(2025, 1, 15, 6, 0, 0).unwrap();
1226        assert!(include.matches(start));
1227
1228        // Last second of hour 22
1229        let end = Stockholm.with_ymd_and_hms(2025, 1, 15, 22, 59, 59).unwrap();
1230        assert!(include.matches(end));
1231
1232        // Last second of hour 5 (just before)
1233        let before = Stockholm.with_ymd_and_hms(2025, 1, 15, 5, 59, 59).unwrap();
1234        assert!(!include.matches(before));
1235
1236        // First second of hour 23 (just after)
1237        let after = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
1238        assert!(!include.matches(after));
1239    }
1240}