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