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 max_consumption <= yearly_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)]
143#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
144pub struct CostPeriods {
145    periods: &'static [CostPeriod],
146}
147
148impl CostPeriods {
149    pub(super) const fn new(periods: &'static [CostPeriod]) -> Self {
150        Self { periods }
151    }
152
153    pub fn iter(&self) -> Iter<'_, CostPeriod> {
154        self.periods.iter()
155    }
156
157    pub(crate) fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
158        self.periods
159            .iter()
160            .any(|cp| cp.is_yearly_consumption_based(fuse_size))
161    }
162}
163
164/// Like CostPeriods, but with costs being simple Money objects
165#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
167pub struct CostPeriodsSimple {
168    periods: Vec<CostPeriodSimple>,
169}
170
171impl CostPeriodsSimple {
172    pub(crate) fn new(
173        periods: CostPeriods,
174        fuse_size: u16,
175        yearly_consumption: u32,
176        language: Language,
177    ) -> Self {
178        Self {
179            periods: periods
180                .periods
181                .iter()
182                .flat_map(|period| {
183                    CostPeriodSimple::new(period, fuse_size, yearly_consumption, language)
184                })
185                .collect(),
186        }
187    }
188}
189
190#[derive(Debug, Clone, Serialize)]
191#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
192pub struct CostPeriod {
193    cost: Cost,
194    load: LoadType,
195    #[serde(serialize_with = "helpers::skip_nones")]
196    include: [Option<Include>; 2],
197    #[serde(serialize_with = "helpers::skip_nones")]
198    exclude: [Option<Exclude>; 2],
199    /// Divide kw by this amount during this period
200    divide_kw_by: u8,
201}
202
203/// Like CostPeriod, but with cost being a simple Money object
204#[derive(Debug, Clone, Serialize)]
205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
206pub(super) struct CostPeriodSimple {
207    cost: Money,
208    load: LoadType,
209    include: Vec<Include>,
210    exclude: Vec<Exclude>,
211    /// Divide kw by this amount during this period
212    divide_kw_by: u8,
213    info: String,
214}
215
216impl CostPeriodSimple {
217    fn new(
218        period: &CostPeriod,
219        fuse_size: u16,
220        yearly_consumption: u32,
221        language: Language,
222    ) -> Option<Self> {
223        let cost = period.cost().cost_for(fuse_size, yearly_consumption)?;
224        Some(
225            Self {
226                cost,
227                load: period.load,
228                include: period.include.into_iter().flatten().collect(),
229                exclude: period.exclude.into_iter().flatten().collect(),
230                divide_kw_by: period.divide_kw_by,
231                info: Default::default(),
232            }
233            .add_info(language),
234        )
235    }
236
237    fn add_info(mut self, language: Language) -> Self {
238        let mut infos = Vec::new();
239        for include in &self.include {
240            infos.push(include.translate(language));
241        }
242        for exclude in &self.exclude {
243            infos.push(exclude.translate(language).into());
244        }
245        self.info = infos.join(", ");
246        self
247    }
248}
249
250impl CostPeriod {
251    pub(super) const fn builder() -> CostPeriodBuilder {
252        CostPeriodBuilder::new()
253    }
254
255    pub const fn cost(&self) -> Cost {
256        self.cost
257    }
258
259    pub const fn load(&self) -> LoadType {
260        self.load
261    }
262
263    pub fn matches(&self, timestamp: DateTime<Tz>) -> bool {
264        for include in self.include_period_types() {
265            if !include.matches(timestamp) {
266                return false;
267            }
268        }
269
270        for exclude in self.exclude_period_types() {
271            if exclude.matches(timestamp) {
272                return false;
273            }
274        }
275        true
276    }
277
278    fn include_period_types(&self) -> Vec<Include> {
279        self.include.iter().flatten().copied().collect()
280    }
281
282    fn exclude_period_types(&self) -> Vec<Exclude> {
283        self.exclude.iter().flatten().copied().collect()
284    }
285
286    fn is_yearly_consumption_based(&self, fuse_size: u16) -> bool {
287        self.cost.is_yearly_consumption_based(fuse_size)
288    }
289}
290
291#[derive(Debug, Clone, Copy, Serialize)]
292#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
293pub enum LoadType {
294    /// Base load. Always counts
295    Base,
296    /// Low load period. Commonly counts during night hours and the summer half of the year
297    Low,
298    /// High load period. Commonly counts during daytime hours and the winter half of the year
299    High,
300}
301
302pub(super) use LoadType::*;
303
304#[derive(Clone)]
305pub(super) struct CostPeriodBuilder {
306    cost: Cost,
307    load: Option<LoadType>,
308    include: [Option<Include>; 2],
309    exclude: [Option<Exclude>; 2],
310    /// Divide kw by this amount during this period
311    divide_kw_by: u8,
312}
313
314impl CostPeriodBuilder {
315    pub(super) const fn new() -> Self {
316        Self {
317            cost: Cost::None,
318            load: None,
319            include: [None; 2],
320            exclude: [None; 2],
321            divide_kw_by: 1,
322        }
323    }
324
325    pub(super) const fn build(self) -> CostPeriod {
326        CostPeriod {
327            cost: self.cost,
328            load: self.load.expect("`load` must be specified"),
329            include: self.include,
330            exclude: self.exclude,
331            divide_kw_by: self.divide_kw_by,
332        }
333    }
334
335    pub(super) const fn cost(mut self, cost: Cost) -> Self {
336        self.cost = cost;
337        self
338    }
339
340    pub(super) const fn load(mut self, load: LoadType) -> Self {
341        self.load = Some(load);
342        self
343    }
344
345    pub(super) const fn fixed_cost(mut self, int: i64, fract: u8) -> Self {
346        self.cost = Cost::fixed(int, fract);
347        self
348    }
349
350    pub(super) const fn fixed_cost_subunit(mut self, subunit: f64) -> Self {
351        self.cost = Cost::fixed_subunit(subunit);
352        self
353    }
354
355    pub(super) const fn include(mut self, period_type: Include) -> Self {
356        let mut i = 0;
357        while i < self.include.len() {
358            if self.include[i].is_some() {
359                i += 1;
360            } else {
361                self.include[i] = Some(period_type);
362                return self;
363            }
364        }
365        panic!("Too many includes");
366    }
367
368    pub(super) const fn months(self, from: Month, to: Month) -> Self {
369        self.include(Include::Months(Months::new(from, to)))
370    }
371
372    pub(super) const fn month(self, month: Month) -> Self {
373        self.include(Include::Month(month))
374    }
375
376    pub(super) const fn hours(self, from: u8, to_inclusive: u8) -> Self {
377        self.include(Include::Hours(Hours::new(from, to_inclusive)))
378    }
379
380    pub(super) const fn exclude(mut self, period_type: Exclude) -> Self {
381        let mut i = 0;
382        while i < self.exclude.len() {
383            if self.exclude[i].is_some() {
384                i += 1;
385            } else {
386                self.exclude[i] = Some(period_type);
387                return self;
388            }
389        }
390        panic!("Too many excludes");
391    }
392
393    pub(super) const fn exclude_holidays(self, country: Country) -> Self {
394        self.exclude(Exclude::Holidays(country))
395    }
396
397    pub(super) const fn exclude_weekends(self) -> Self {
398        self.exclude(Exclude::Weekends)
399    }
400
401    pub(super) const fn divide_kw_by(mut self, value: u8) -> Self {
402        self.divide_kw_by = value;
403        self
404    }
405}
406
407#[derive(Debug, Clone, Copy, Serialize)]
408#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
409pub(super) enum Include {
410    Months(Months),
411    Month(Month),
412    Hours(Hours),
413}
414
415impl Include {
416    fn translate(&self, language: Language) -> String {
417        match self {
418            Include::Months(months) => months.translate(language),
419            Include::Month(month) => month.translate(language).into(),
420            Include::Hours(hours) => hours.translate(language),
421        }
422    }
423
424    fn matches(&self, timestamp: DateTime<Tz>) -> bool {
425        match self {
426            Include::Months(months) => months.matches(timestamp),
427            Include::Month(month) => month.matches(timestamp),
428            Include::Hours(hours) => hours.matches(timestamp),
429        }
430    }
431}
432
433#[derive(Debug, Clone, Copy, Serialize)]
434#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
435pub(super) enum Exclude {
436    Weekends,
437    Holidays(Country),
438}
439
440impl Exclude {
441    pub(super) fn translate(&self, language: Language) -> &'static str {
442        match language {
443            Language::En => match self {
444                Exclude::Weekends => "Weekends",
445                Exclude::Holidays(country) => match country {
446                    Country::SE => "Swedish holidays",
447                },
448            },
449            Language::Sv => match self {
450                Exclude::Weekends => "Helg",
451                Exclude::Holidays(country) => match country {
452                    Country::SE => "Svenska helgdagar",
453                },
454            },
455        }
456    }
457
458    fn matches(&self, timestamp: DateTime<Tz>) -> bool {
459        match self {
460            Exclude::Weekends => (6..=7).contains(&timestamp.weekday().number_from_monday()),
461            Exclude::Holidays(country) => country.is_holiday(timestamp.date_naive()),
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::defs::{Hours, Month, Months};
470    use crate::money::Money;
471    use chrono::TimeZone;
472    use chrono_tz::Europe::Stockholm;
473
474    #[test]
475    fn fuse_based_cost() {
476        const FUSE_BASED: Cost = Cost::fuse_range(&[
477            (16, 35, Money::new(54, 0)),
478            (35, u16::MAX, Money::new(108, 50)),
479        ]);
480        assert_eq!(FUSE_BASED.cost_for(10, 0), None);
481        assert_eq!(FUSE_BASED.cost_for(25, 0), Some(Money::new(54, 0)));
482        assert_eq!(FUSE_BASED.cost_for(200, 0), Some(Money::new(108, 50)));
483    }
484
485    #[test]
486    fn include_matches_hours() {
487        let include = Include::Hours(Hours::new(6, 22));
488        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
489        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
490
491        assert!(include.matches(timestamp_match));
492        assert!(!include.matches(timestamp_no_match));
493    }
494
495    #[test]
496    fn include_matches_month() {
497        let include = Include::Month(Month::June);
498        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
499        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
500
501        assert!(include.matches(timestamp_match));
502        assert!(!include.matches(timestamp_no_match));
503    }
504
505    #[test]
506    fn include_matches_months() {
507        let include = Include::Months(Months::new(Month::November, Month::March));
508        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 12, 0, 0).unwrap();
509        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 7, 15, 12, 0, 0).unwrap();
510
511        assert!(include.matches(timestamp_match));
512        assert!(!include.matches(timestamp_no_match));
513    }
514
515    #[test]
516    fn exclude_matches_weekends_saturday() {
517        let exclude = Exclude::Weekends;
518        // January 4, 2025 is a Saturday
519        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
520        assert!(exclude.matches(timestamp));
521    }
522
523    #[test]
524    fn exclude_matches_weekends_sunday() {
525        let exclude = Exclude::Weekends;
526        // January 5, 2025 is a Sunday
527        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 5, 12, 0, 0).unwrap();
528        assert!(exclude.matches(timestamp));
529    }
530
531    #[test]
532    fn exclude_does_not_match_weekday() {
533        let exclude = Exclude::Weekends;
534        // January 6, 2025 is a Monday
535        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).unwrap();
536        assert!(!exclude.matches(timestamp));
537    }
538
539    #[test]
540    fn exclude_matches_swedish_new_year() {
541        let exclude = Exclude::Holidays(Country::SE);
542        // January 1 is a Swedish holiday
543        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 1, 12, 0, 0).unwrap();
544        assert!(exclude.matches(timestamp));
545    }
546
547    #[test]
548    fn exclude_does_not_match_non_holiday() {
549        let exclude = Exclude::Holidays(Country::SE);
550        // January 2, 2025 is not a Swedish holiday
551        let timestamp = Stockholm.with_ymd_and_hms(2025, 1, 2, 12, 0, 0).unwrap();
552        assert!(!exclude.matches(timestamp));
553    }
554
555    #[test]
556    fn cost_period_matches_with_single_include() {
557        let period = CostPeriod::builder()
558            .load(LoadType::High)
559            .fixed_cost(10, 0)
560            .hours(6, 22)
561            .build();
562
563        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
564        let timestamp_no_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
565
566        assert!(period.matches(timestamp_match));
567        assert!(!period.matches(timestamp_no_match));
568    }
569
570    #[test]
571    fn cost_period_matches_with_multiple_includes() {
572        let period = CostPeriod::builder()
573            .load(LoadType::High)
574            .fixed_cost(10, 0)
575            .hours(6, 22)
576            .months(Month::November, Month::March)
577            .build();
578
579        // Winter daytime - should match
580        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
581        // Winter nighttime - should not match (wrong hours)
582        let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
583        // Summer daytime - should not match (wrong months)
584        let timestamp_wrong_months = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
585
586        assert!(period.matches(timestamp_match));
587        assert!(!period.matches(timestamp_wrong_hours));
588        assert!(!period.matches(timestamp_wrong_months));
589    }
590
591    #[test]
592    fn cost_period_matches_with_exclude_weekends() {
593        let period = CostPeriod::builder()
594            .load(LoadType::High)
595            .fixed_cost(10, 0)
596            .hours(6, 22)
597            .exclude_weekends()
598            .build();
599
600        println!("Excludes: {:?}", period.exclude_period_types());
601        println!("Includes: {:?}", period.include_period_types());
602
603        // Monday daytime - should match
604        let timestamp_weekday = Stockholm.with_ymd_and_hms(2025, 1, 6, 14, 0, 0).unwrap();
605        // Saturday daytime - should not match (excluded)
606        let timestamp_saturday = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
607
608        assert!(period.matches(timestamp_weekday));
609        assert!(!period.matches(timestamp_saturday));
610    }
611
612    #[test]
613    fn cost_period_matches_with_exclude_holidays() {
614        let period = CostPeriod::builder()
615            .load(LoadType::High)
616            .fixed_cost(10, 0)
617            .hours(6, 22)
618            .exclude_holidays(Country::SE)
619            .build();
620
621        // Regular weekday - should match
622        let timestamp_regular = Stockholm.with_ymd_and_hms(2025, 1, 2, 14, 0, 0).unwrap();
623        // New Year's Day - should not match (excluded)
624        let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
625
626        assert!(period.matches(timestamp_regular));
627        assert!(!period.matches(timestamp_holiday));
628    }
629
630    #[test]
631    fn cost_period_matches_complex_scenario() {
632        // Winter high load period: Nov-Mar, 6-22, excluding weekends and holidays
633        let period = CostPeriod::builder()
634            .load(LoadType::High)
635            .fixed_cost(10, 0)
636            .months(Month::November, Month::March)
637            .hours(6, 22)
638            .exclude_weekends()
639            .exclude_holidays(Country::SE)
640            .build();
641
642        // Winter weekday daytime (not holiday) - should match
643        let timestamp_match = Stockholm.with_ymd_and_hms(2025, 1, 15, 14, 0, 0).unwrap();
644
645        // Winter weekday nighttime - should not match (wrong hours)
646        let timestamp_wrong_hours = Stockholm.with_ymd_and_hms(2025, 1, 15, 23, 0, 0).unwrap();
647
648        // Winter Saturday daytime - should not match (weekend)
649        let timestamp_weekend = Stockholm.with_ymd_and_hms(2025, 1, 4, 14, 0, 0).unwrap();
650
651        // New Year's Day (holiday) - should not match
652        let timestamp_holiday = Stockholm.with_ymd_and_hms(2025, 1, 1, 14, 0, 0).unwrap();
653
654        // Summer weekday daytime - should not match (wrong months)
655        let timestamp_summer = Stockholm.with_ymd_and_hms(2025, 7, 15, 14, 0, 0).unwrap();
656
657        assert!(period.matches(timestamp_match));
658        assert!(!period.matches(timestamp_wrong_hours));
659        assert!(!period.matches(timestamp_weekend));
660        assert!(!period.matches(timestamp_holiday));
661        assert!(!period.matches(timestamp_summer));
662    }
663
664    #[test]
665    fn cost_period_matches_base_load() {
666        // Base load period with no restrictions
667        let period = CostPeriod::builder()
668            .load(LoadType::Base)
669            .fixed_cost(5, 0)
670            .build();
671
672        // Should match any time
673        let timestamp1 = Stockholm.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
674        let timestamp2 = Stockholm.with_ymd_and_hms(2025, 7, 15, 23, 59, 59).unwrap();
675        let timestamp3 = Stockholm.with_ymd_and_hms(2025, 1, 4, 12, 0, 0).unwrap();
676
677        assert!(period.matches(timestamp1));
678        assert!(period.matches(timestamp2));
679        assert!(period.matches(timestamp3));
680    }
681}