grid_tariffs/
costs.rs

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