ocpi_tariffs/
generate.rs

1mod v2x;
2
3use std::{
4    cmp::{max, min},
5    fmt,
6    ops::Range,
7};
8
9use chrono::{DateTime, Datelike as _, NaiveDateTime, NaiveTime, TimeDelta, Utc};
10use rust_decimal::{prelude::ToPrimitive, Decimal};
11use rust_decimal_macros::dec;
12
13use crate::{
14    country, currency,
15    duration::ToHoursDecimal,
16    energy::{Ampere, Kw, Kwh},
17    into_caveat_all,
18    json::FromJson as _,
19    number::FromDecimal as _,
20    price,
21    tariff::{self, WarningKind},
22    warning::{self, GatherWarnings as _, IntoCaveat},
23    Price, Version, Versioned,
24};
25
26/// The minimum duration of a CDR. Anything below this will result in an Error.
27const MIN_CS_DURATION_SECS: i64 = 120;
28
29type DateTimeSpan = Range<DateTime<Utc>>;
30
31/// Return the value if `Some`. Otherwise, bail(return) with an `Error::Internal` containing the giving message.
32macro_rules! some_or_bail_msg {
33    ($opt:expr, $($tokens:tt)*) => {
34        match $opt {
35            Some(v) => v,
36            None => {
37                return Err(Error::Internal(
38                    format!($($tokens)*).into(),
39                ));
40            }
41        }
42    }
43}
44
45/// The outcome of calling [`crate::cdr::generate_from_tariff`].
46#[derive(Debug)]
47pub struct Report {
48    /// The ID of the parsed tariff.
49    pub tariff_id: String,
50
51    // The currency code of the parsed tariff.
52    pub tariff_currency_code: currency::Code,
53
54    /// A partial CDR that can be fleshed out by the caller.
55    ///
56    /// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
57    /// does not know anything about the EVSE location or the token used to authenticate the chargesession.
58    ///
59    /// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
60    pub partial_cdr: PartialCdr,
61}
62
63/// A partial CDR generated by the `cdr_from_tariff` function.
64///
65/// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
66/// does not know anything about the EVSE location or the token used to authenticate the chargesession.
67///
68/// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
69/// * See: [OCPI spec 2.1.1: Tariff](https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md)
70#[derive(Debug)]
71pub struct PartialCdr {
72    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this CDR.
73    pub country_code: Option<country::Code>,
74
75    /// ID of the CPO that 'owns' this CDR (following the ISO-15118 standard).
76    pub party_id: Option<String>,
77
78    /// Start timestamp of the charging session.
79    pub start_date_time: DateTime<Utc>,
80
81    /// End timestamp of the charging session.
82    pub end_date_time: DateTime<Utc>,
83
84    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this CDR.
85    pub currency: currency::Code,
86
87    /// Total energy charged, in kWh.
88    pub total_energy: Option<Kwh>,
89
90    /// Total time charging.
91    pub total_time: Option<TimeDelta>,
92
93    /// Total time not charging.
94    pub total_parking_time: Option<TimeDelta>,
95
96    /// Total cost of this transaction.
97    pub total_cost: Option<Price>,
98
99    /// Total cost related to the energy dimension.
100    pub total_energy_cost: Option<Price>,
101
102    /// Total cost of the flat dimension.
103    pub total_fixed_cost: Option<Price>,
104
105    /// Total cost related to the parking time dimension.
106    pub total_parking_cost: Option<Price>,
107
108    /// Total cost related to the charging time dimension.
109    pub total_time_cost: Option<Price>,
110
111    /// List of charging periods that make up this charging session. A session should consist of 1 or
112    /// more periods, where each period has a different relevant Tariff.
113    pub charging_periods: Vec<ChargingPeriod>,
114}
115
116/// A single charging period, containing a nonempty list of charge dimensions.
117///
118/// * See: [OCPI spec 2.2.1: CDR ChargingPeriod](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#146-chargingperiod-class>)
119#[derive(Debug)]
120pub struct ChargingPeriod {
121    /// Start timestamp of the charging period. This period ends when a next period starts, the
122    /// last period ends when the session ends
123    pub start_date_time: DateTime<Utc>,
124
125    /// List of relevant values for this charging period.
126    pub dimensions: Vec<Dimension>,
127
128    /// Unique identifier of the Tariff that is relevant for this Charging Period.
129    /// In the OCPI spec the `tariff_id` field is optional but, we always know the tariff ID
130    /// when generating a CDR.
131    pub tariff_id: Option<String>,
132}
133
134/// The volume that has been consumed for a specific dimension during a charging period.
135///
136/// * See: [OCPI spec 2.2.1: CDR Dimension](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdrdimension_class>)
137#[derive(Debug)]
138pub struct Dimension {
139    pub dimension_type: DimensionType,
140
141    /// Volume of the dimension consumed, measured according to the dimension type.
142    pub volume: Decimal,
143}
144
145/// The volume that has been consumed for a specific dimension during a charging period.
146///
147/// * See: [OCPI spec 2.2.1 CDR DimensionType](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#mod_cdrs_cdrdimension_class>)
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum DimensionType {
150    /// Consumed energy in `kWh`.
151    Energy,
152    /// The peak current, in 'A', during this period.
153    MaxCurrent,
154    /// The lowest current, in `A`, during this period.
155    MinCurrent,
156    /// The maximum power, in 'kW', reached during this period.
157    MaxPower,
158    /// The minimum power, in 'kW', reached during this period.
159    MinPower,
160    /// The parking time, in hours, consumed in this period.
161    ParkingTime,
162    /// The reservation time, in hours, consumed in this period.
163    ReservationTime,
164    /// The charging time, in hours, consumed in this period.
165    Time,
166}
167
168into_caveat_all!(Report, Timeline);
169
170/// Generate a CDR from a given tariff.
171pub fn cdr_from_tariff(
172    tariff_elem: &tariff::Versioned<'_>,
173    config: Config,
174) -> Result<Caveat<Report>> {
175    // To generate a CDR from a tariff first, the tariff is parsed into structured data.
176    // Then some broad metrics are calculated that define limits on the chargesession.
177    //
178    // A Timeline of Events is then constructed by generating Events for each Element and each restriction.
179    // Some restrictions are periodic and can result in many `Event`s.
180    //
181    // The `Timeline` of `Event`s are then sorted by time and converted into a list of `ChargePeriods`.
182    let (metrics, timezone) = metrics(config)?;
183
184    let mut warnings = warning::Set::new();
185    let tariff = match tariff_elem.version() {
186        Version::V211 => {
187            let tariff = tariff::v211::Tariff::from_json(tariff_elem.as_element())?
188                .gather_warnings_into(&mut warnings);
189
190            tariff::v221::Tariff::from(tariff)
191        }
192        Version::V221 => tariff::v221::Tariff::from_json(tariff_elem.as_element())?
193            .gather_warnings_into(&mut warnings),
194    };
195
196    if !is_tariff_active(&metrics.start_date_time, &tariff) {
197        warnings.with_elem(WarningKind::NotActive, tariff_elem.as_element());
198    }
199
200    let timeline = timeline(timezone, &metrics, &tariff);
201    let mut charging_periods = charge_periods(&metrics, timeline);
202
203    let report = price::periods(
204        metrics.end_date_time,
205        timezone,
206        &tariff,
207        &mut charging_periods,
208    )?;
209
210    let price::PeriodsReport {
211        billable: _,
212        periods,
213        totals,
214        total_costs,
215    } = report;
216
217    let charging_periods = periods
218        .into_iter()
219        .map(|period| {
220            let price::PeriodReport {
221                start_date_time,
222                end_date_time: _,
223                dimensions,
224            } = period;
225            let time = dimensions
226                .duration_charging
227                .volume
228                .as_ref()
229                .map(|dt| Dimension {
230                    dimension_type: DimensionType::Time,
231                    volume: ToHoursDecimal::to_hours_dec(dt),
232                });
233            let parking_time = dimensions
234                .duration_parking
235                .volume
236                .as_ref()
237                .map(|dt| Dimension {
238                    dimension_type: DimensionType::ParkingTime,
239                    volume: ToHoursDecimal::to_hours_dec(dt),
240                });
241            let energy = dimensions.energy.volume.as_ref().map(|kwh| Dimension {
242                dimension_type: DimensionType::Energy,
243                volume: (*kwh).into(),
244            });
245            let dimensions = vec![energy, parking_time, time]
246                .into_iter()
247                .flatten()
248                .collect();
249
250            ChargingPeriod {
251                start_date_time,
252                dimensions,
253                tariff_id: Some(tariff.id.to_string()),
254            }
255        })
256        .collect();
257
258    let mut total_cost = total_costs.total();
259
260    if let Some(total_cost) = total_cost.as_mut() {
261        if let Some(min_price) = tariff.min_price {
262            if *total_cost < min_price {
263                *total_cost = min_price;
264                warnings.with_elem(WarningKind::TotalCostClampedToMin, tariff_elem.as_element());
265            }
266        }
267
268        if let Some(max_price) = tariff.max_price {
269            if *total_cost > max_price {
270                *total_cost = max_price;
271                warnings.with_elem(WarningKind::TotalCostClampedToMin, tariff_elem.as_element());
272            }
273        }
274    }
275
276    let report = Report {
277        tariff_id: tariff.id.to_string(),
278        tariff_currency_code: tariff.currency,
279        partial_cdr: PartialCdr {
280            country_code: tariff.country_code,
281            party_id: tariff.party_id.as_ref().map(ToString::to_string),
282            start_date_time: metrics.start_date_time,
283            end_date_time: metrics.end_date_time,
284            currency: tariff.currency,
285            total_energy: totals.energy,
286            total_time: totals.duration_charging,
287            total_parking_time: totals.duration_parking,
288            total_cost,
289            total_energy_cost: total_costs.energy,
290            total_fixed_cost: total_costs.fixed,
291            total_parking_cost: total_costs.duration_parking,
292            total_time_cost: total_costs.duration_charging,
293            charging_periods,
294        },
295    };
296
297    Ok(report.into_caveat(warnings))
298}
299
300/// Make a `Timeline` of `Event`s using the `Metric`s and `Tariff`.
301fn timeline(
302    timezone: chrono_tz::Tz,
303    metrics: &Metrics,
304    tariff: &tariff::v221::Tariff<'_>,
305) -> Timeline {
306    let mut events = vec![];
307
308    let Metrics {
309        start_date_time: cdr_start,
310        end_date_time: cdr_end,
311        duration_charging,
312        duration_parking,
313        max_power_supply,
314        max_current_supply,
315
316        energy_supplied: _,
317    } = metrics;
318
319    events.push(Event {
320        duration_from_start: TimeDelta::seconds(0),
321        kind: EventKind::SessionStart,
322    });
323
324    events.push(Event {
325        duration_from_start: *duration_charging,
326        kind: EventKind::ChargingEnd,
327    });
328
329    if let Some(duration_parking) = duration_parking {
330        events.push(Event {
331            duration_from_start: *duration_parking,
332            kind: EventKind::ParkingEnd {
333                start: metrics.duration_charging,
334            },
335        });
336    }
337
338    // True if `min_current` or `max_current` restrictions are defined.
339    // Then we set current to be consumed for each period.
340    let mut emit_current = false;
341
342    // True if `min_power` or `max_power` restrictions are defined.
343    // Then we set power to be consumed for each period.
344    let mut emit_power = false;
345
346    for elem in &tariff.elements {
347        if let Some((time_restrictions, energy_restrictions)) = elem
348            .restrictions
349            .as_ref()
350            .map(tariff::v221::Restrictions::restrictions_by_category)
351        {
352            let mut time_events =
353                generate_time_events(timezone, *cdr_start..*cdr_end, time_restrictions);
354
355            let v2x::EnergyRestrictions {
356                min_kwh,
357                max_kwh,
358                min_current,
359                max_current,
360                min_power,
361                max_power,
362            } = energy_restrictions;
363
364            if !emit_current {
365                // If the generator current is contained within the restriction, then we set
366                // an amount of current to be consumed for each period.
367                //
368                // Note: The generator supplies maximum current.
369                emit_current = (min_current..=max_current).contains(&Some(*max_current_supply));
370            }
371
372            if !emit_power {
373                // If the generator power is contained within the restriction, then we set
374                // an amount of power to be consumed for each period.
375                //
376                // Note: The generator supplies maximum power.
377                emit_power = (min_power..=max_power).contains(&Some(*max_power_supply));
378            }
379
380            let mut energy_events = generate_energy_events(
381                metrics.duration_charging,
382                metrics.energy_supplied,
383                min_kwh,
384                max_kwh,
385            );
386
387            events.append(&mut time_events);
388            events.append(&mut energy_events);
389        }
390    }
391
392    Timeline {
393        events,
394        emit_current,
395        emit_power,
396    }
397}
398
399/// Generate a list of `Event`s based on the `TimeRestrictions` an `Element` has.
400fn generate_time_events(
401    timezone: chrono_tz::Tz,
402    cdr_span: DateTimeSpan,
403    restrictions: v2x::TimeRestrictions,
404) -> Vec<Event> {
405    const MIDNIGHT: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0)
406        .expect("The hour, minute and second values are correct and hardcoded");
407    const ONE_DAY: TimeDelta = TimeDelta::days(1);
408
409    let v2x::TimeRestrictions {
410        start_time,
411        end_time,
412        start_date,
413        end_date,
414        min_duration,
415        max_duration,
416        weekdays,
417    } = restrictions;
418    let mut events = vec![];
419
420    let cdr_duration = cdr_span.end - cdr_span.start;
421
422    // If `min_duration` occur within the duration of the chargesession add an event.
423    if let Some(min_duration) = min_duration.filter(|dt| &cdr_duration < dt) {
424        events.push(Event {
425            duration_from_start: min_duration,
426            kind: EventKind::MinDuration,
427        });
428    }
429
430    // If `max_duration` occur within the duration of the chargesession add an event.
431    if let Some(max_duration) = max_duration.filter(|dt| &cdr_duration < dt) {
432        events.push(Event {
433            duration_from_start: max_duration,
434            kind: EventKind::MaxDuration,
435        });
436    }
437
438    // Here we create the `NaiveDateTime` range by combining the `start_date` (`NaiveDate`) and
439    // `start_time` (`NaiveTime`) and the associated `end_date` and `end_time`.
440    //
441    // If `start_time` or `end_time` are `None` then their respective `NaiveDate` is combined
442    // with the `NaiveTime` of `00:00:00` to form a `NaiveDateTime`.
443    //
444    // If the `end_time < start_time` then the period wraps around to the following day.
445    //
446    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>
447    let (start_date_time, end_date_time) =
448        if let (Some(start_time), Some(end_time)) = (start_time, end_time) {
449            if end_time < start_time {
450                (
451                    start_date.map(|d| d.and_time(start_time)),
452                    end_date.map(|d| d.and_time(end_time + ONE_DAY)),
453                )
454            } else {
455                (
456                    start_date.map(|d| d.and_time(start_time)),
457                    end_date.map(|d| d.and_time(end_time)),
458                )
459            }
460        } else {
461            (
462                start_date.map(|d| d.and_time(start_time.unwrap_or(MIDNIGHT))),
463                end_date.map(|d| d.and_time(end_time.unwrap_or(MIDNIGHT))),
464            )
465        };
466
467    // If `start_date` or `end_date` is set we clamp the cdr_span to those dates.
468    // As we are not going to produce any events before `start_date` or after `end_date`.
469    let event_span = clamp_date_time_span(
470        start_date_time.and_then(|d| local_to_utc(timezone, d)),
471        end_date_time.and_then(|d| local_to_utc(timezone, d)),
472        cdr_span,
473    );
474
475    if let Some(start_time) = start_time {
476        let mut start_events =
477            gen_naive_time_events(&event_span, start_time, &weekdays, EventKind::StartTime);
478        events.append(&mut start_events);
479    }
480
481    if let Some(end_time) = end_time {
482        let mut end_events =
483            gen_naive_time_events(&event_span, end_time, &weekdays, EventKind::EndTime);
484        events.append(&mut end_events);
485    }
486
487    events
488}
489
490/// Convert a `NaiveDateTime` to a `DateTime<Utc>` using the local timezone.
491///
492/// Return Some `DateTime<Utc>` if the conversion from `NaiveDateTime` results in either a single
493/// or ambiguous `DateTime`. If the conversion is _ambiguous_ due to a _fold_ in the local time,
494/// then we return the earliest `DateTime`.
495fn local_to_utc(timezone: chrono_tz::Tz, date_time: NaiveDateTime) -> Option<DateTime<Utc>> {
496    use chrono::offset::LocalResult;
497
498    let result = date_time.and_local_timezone(timezone);
499
500    let local_date_time = match result {
501        LocalResult::Single(d) => d,
502        LocalResult::Ambiguous(earliest, _latest) => earliest,
503        LocalResult::None => return None,
504    };
505
506    Some(local_date_time.to_utc())
507}
508
509/// Generate `Event`s for the `start_time` or `end_time` restriction.
510fn gen_naive_time_events(
511    event_span: &Range<DateTime<Utc>>,
512    time: NaiveTime,
513    weekdays: &v2x::WeekdaySet,
514    kind: EventKind,
515) -> Vec<Event> {
516    let mut events = vec![];
517    let time_delta = time - event_span.start.time();
518    let cdr_duration = event_span.end - event_span.start;
519
520    // If the start time is before the CDR start, we move it forward 24hours
521    // and test again.
522    let time_delta = if time_delta.num_seconds().is_negative() {
523        let time_delta = time + TimeDelta::days(1);
524        time_delta - event_span.start.time()
525    } else {
526        time_delta
527    };
528
529    // If the start delta is still negative after moving it forward 24 hours
530    if time_delta.num_seconds().is_negative() {
531        return vec![];
532    }
533
534    // The time is after the CDR start.
535    let remainder = cdr_duration - time_delta;
536
537    if remainder.num_seconds().is_positive() {
538        let duration_from_start = time_delta;
539        let date = event_span.start + duration_from_start;
540
541        if weekdays.contains(date.weekday()) {
542            // The time is before the CDR end.
543            events.push(Event {
544                duration_from_start: time_delta,
545                kind,
546            });
547        }
548
549        for day in 1..=remainder.num_days() {
550            let duration_from_start = time_delta + TimeDelta::days(day);
551            let date = event_span.start + duration_from_start;
552
553            if weekdays.contains(date.weekday()) {
554                events.push(Event {
555                    duration_from_start,
556                    kind,
557                });
558            }
559        }
560    }
561
562    events
563}
564
565/// Generate a list of `Event`s based on the `TimeRestrictions` an `Element` has.
566fn generate_energy_events(
567    duration_charging: TimeDelta,
568    energy_supplied: Kwh,
569    min_kwh: Option<Kwh>,
570    max_kwh: Option<Kwh>,
571) -> Vec<Event> {
572    let mut events = vec![];
573
574    if let Some(duration_from_start) =
575        min_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
576    {
577        events.push(Event {
578            duration_from_start,
579            kind: EventKind::MinKwh,
580        });
581    }
582
583    if let Some(duration_from_start) =
584        max_kwh.and_then(|kwh| energy_factor(kwh, energy_supplied, duration_charging))
585    {
586        events.push(Event {
587            duration_from_start,
588            kind: EventKind::MaxKwh,
589        });
590    }
591
592    events
593}
594
595fn energy_factor(power: Kwh, power_total: Kwh, duration_total: TimeDelta) -> Option<TimeDelta> {
596    use rust_decimal::prelude::ToPrimitive;
597
598    // Find the time that the `min_kwh` amount of power was reached.
599    // It has to be within the charging time.
600    let power = Decimal::from(power);
601    // The total power supplied during the chargesession
602    let power_total = Decimal::from(power_total);
603    // The factor minimum of the total power supplied.
604    let factor = power_total / power;
605
606    if factor.is_sign_negative() || factor > dec!(1.0) {
607        return None;
608    }
609
610    let duration_from_start = factor * Decimal::from(duration_total.num_seconds());
611    duration_from_start.to_i64().map(TimeDelta::seconds)
612}
613
614/// Generate a list of charging periods for the given tariffs timeline.
615fn charge_periods(metrics: &Metrics, timeline: Timeline) -> Vec<price::Period> {
616    /// Keep track of the whether we are charging or parking.
617    enum ChargingPhase {
618        Charging,
619        Parking,
620    }
621
622    let Metrics {
623        start_date_time: cdr_start,
624        max_power_supply,
625        max_current_supply,
626
627        end_date_time: _,
628        duration_charging: _,
629        duration_parking: _,
630        energy_supplied: _,
631    } = metrics;
632
633    let Timeline {
634        mut events,
635        emit_current,
636        emit_power,
637    } = timeline;
638
639    events.sort_unstable_by_key(|e| e.duration_from_start);
640
641    let mut periods = vec![];
642    let emit_current = emit_current.then_some(*max_current_supply);
643    let emit_power = emit_power.then_some(*max_power_supply);
644    // Charging starts instantly in this model.
645    let mut charging_phase = ChargingPhase::Charging;
646
647    for items in events.windows(2) {
648        let [event, event_next] = items else {
649            unreachable!("The window size is 2");
650        };
651
652        let Event {
653            duration_from_start,
654            kind,
655        } = event;
656
657        if let EventKind::ChargingEnd = kind {
658            charging_phase = ChargingPhase::Parking;
659        }
660
661        let duration = event_next.duration_from_start - *duration_from_start;
662        let start_date_time = *cdr_start + *duration_from_start;
663
664        let consumed = if let ChargingPhase::Charging = charging_phase {
665            let energy = Decimal::from(*max_power_supply) * duration.to_hours_dec();
666            price::Consumed {
667                duration_charging: Some(duration),
668                duration_parking: None,
669                energy: Some(Kwh::from_decimal(energy)),
670                current_max: emit_current,
671                current_min: emit_current,
672                power_max: emit_power,
673                power_min: emit_power,
674            }
675        } else {
676            price::Consumed {
677                duration_charging: None,
678                duration_parking: Some(duration),
679                energy: None,
680                current_max: None,
681                current_min: None,
682                power_max: None,
683                power_min: None,
684            }
685        };
686
687        let period = price::Period {
688            start_date_time,
689            consumed,
690        };
691
692        periods.push(period);
693    }
694
695    periods
696}
697
698/// A `DateTimeSpan` bounded by a minimum and a maximum
699///
700/// If the input `DateTimeSpan` is less than `min_date` then this returns `min_date`.
701/// If input is greater than `max_date` then this returns `max_date`.
702/// Otherwise, this returns input `DateTimeSpan`.
703fn clamp_date_time_span(
704    min_date: Option<DateTime<Utc>>,
705    max_date: Option<DateTime<Utc>>,
706    span: DateTimeSpan,
707) -> DateTimeSpan {
708    // Make sure the `min_date` is the earlier of the `min`, max pair.
709    let (min_date, max_date) = (min(min_date, max_date), max(min_date, max_date));
710
711    let start = min_date.filter(|d| &span.start < d).unwrap_or(span.start);
712    let end = max_date.filter(|d| &span.end > d).unwrap_or(span.end);
713
714    DateTimeSpan { start, end }
715}
716
717/// A timeline of events that are used to generate the `ChargePeriods` of the CDR.
718struct Timeline {
719    /// The list of `Event`s generated from the tariff.
720    events: Vec<Event>,
721
722    /// The current is within the \[`min_current`..`max_current`\] range.
723    emit_current: bool,
724
725    /// The power is within the \[`min_power`..`max_power`\] range.
726    emit_power: bool,
727}
728
729/// An event at a time along the timeline.
730#[derive(Debug)]
731struct Event {
732    /// The duration of the Event from the start of the timeline/chargesession.
733    duration_from_start: TimeDelta,
734
735    /// The kind of Event.
736    kind: EventKind,
737}
738
739/// The kind of `Event`.
740#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
741enum EventKind {
742    /// The moment a session starts.
743    ///
744    /// This is added to the list of `Event`s so that the algorithm to generate the `ChargingPeriods`
745    /// can iterate over the `Event`s using a window of size 2. The first iteration will always have
746    /// `SessionStart` as the first window element and the `Event` of interest as the second.
747    SessionStart,
748
749    /// The moment charging ends.
750    ///
751    /// Charging starts at time 0. When `ChargingEnd`s, parking starts.
752    /// This could also be the last `Event` of the chargesession.
753    ChargingEnd,
754
755    /// The moment Parking ends
756    ///
757    /// This could also be the last `Event` of the chargesession.
758    /// If a `ParkingEnd` `Event` is present in the `Timeline` then a `ChargingEnd` `Event` will precede it.
759    ParkingEnd {
760        /// The parking started when `ChargingEnd`ed.
761        start: TimeDelta,
762    },
763
764    StartTime,
765
766    EndTime,
767
768    /// Minimum duration in seconds the Charging Session MUST last (inclusive).
769    ///
770    /// When the duration of a Charging Session is longer than the defined value, this `TariffElement` is or becomes active.
771    /// Before that moment, this `TariffElement` is not yet active.
772    MinDuration,
773
774    /// Maximum duration in seconds the Charging Session MUST last (exclusive).
775    ///
776    /// When the duration of a Charging Session is shorter than the defined value, this `TariffElement` is or becomes active.
777    /// After that moment, this `TariffElement` is no longer active.
778    MaxDuration,
779
780    /// Minimum consumed energy in kWh, for example 20, valid from this amount of energy (inclusive) being used.
781    MinKwh,
782
783    /// Maximum consumed energy in kWh, for example 50, valid until this amount of energy (exclusive) being used.
784    MaxKwh,
785}
786
787/// Broad metrics calculated about the chargesession which is given as input for generating a `Timeline` of `Event`s.
788#[derive(Debug)]
789struct Metrics {
790    /// The end date the generated CDR.
791    end_date_time: DateTime<Utc>,
792
793    /// The start date the generated CDR.
794    start_date_time: DateTime<Utc>,
795
796    /// The time spent charging the battery.
797    ///
798    /// Charging begins instantly and continues without interruption until the battery is full or the
799    /// session time has elapsed.
800    duration_charging: TimeDelta,
801
802    /// The time spent parking after charging the battery.
803    ///
804    /// This duration may be `None` if the battery did not finish charging within the session time.
805    duration_parking: Option<TimeDelta>,
806
807    /// The energy that's supplied during the charging period.
808    energy_supplied: Kwh,
809
810    /// The maximum DC current that can be delivered to the battery.
811    max_current_supply: Ampere,
812
813    /// The maximum DC power(kw) that can be delivered to the battery.
814    max_power_supply: Kw,
815}
816
817/// Validate the `Config` and compute various `Metrics` based on the `Config`s fields.
818#[expect(
819    clippy::needless_pass_by_value,
820    reason = "Clippy is complaining that `Config` is not consumed by the function when it clearly is"
821)]
822fn metrics(config: Config) -> Result<(Metrics, chrono_tz::Tz)> {
823    const SECS_IN_HOUR: Decimal = dec!(3600);
824
825    let Config {
826        start_date_time,
827        end_date_time,
828        max_power_supply,
829        max_energy_battery,
830        max_current_supply,
831        timezone,
832    } = config;
833    let duration_session = end_date_time - start_date_time;
834
835    // Std Duration must be positive, if the end time is before the start the conversion will fail.
836    if duration_session.num_seconds().is_negative() {
837        return Err(Error::StartDateTimeIsAfterEndDateTime);
838    }
839
840    if duration_session.num_seconds() < MIN_CS_DURATION_SECS {
841        return Err(Error::DurationBelowMinimum);
842    }
843
844    let max_energy_battery = Decimal::from(max_energy_battery);
845    // The time needed to charge the battery = battery_capacity(kWh) / power(kw)
846    let duration_full_charge_hours = some_or_bail_msg!(
847        max_energy_battery.checked_div(Decimal::from(max_power_supply)),
848        "Unable to calculate changing time"
849    );
850
851    // The charge duration taking into account that the end of the session can occur before the battery is fully charged.
852    let charge_duration_hours =
853        Decimal::min(duration_full_charge_hours, duration_session.to_hours_dec());
854
855    let power_supplied_kwh = some_or_bail_msg!(
856        max_energy_battery.checked_div(charge_duration_hours),
857        "Unable to calculate the power supplied during the charging time"
858    );
859
860    // Convert duration from hours to seconds as we work with seconds as the unit of time.
861    let charging_duration_secs = some_or_bail_msg!(
862        charge_duration_hours.checked_mul(SECS_IN_HOUR),
863        "Unable to convert charging time from hours to seconds"
864    );
865
866    let charging_duration_secs = some_or_bail_msg!(
867        charging_duration_secs.to_i64(),
868        "Unable to convert charging duration Decimal to i64"
869    );
870    let duration_charging = TimeDelta::seconds(charging_duration_secs);
871
872    let duration_parking = some_or_bail_msg!(
873        duration_session.checked_sub(&duration_charging),
874        "Unable to calculate `idle_duration`"
875    );
876
877    let metrics = Metrics {
878        end_date_time,
879        start_date_time,
880        duration_charging,
881        duration_parking: Some(duration_parking).filter(|dt| dt.num_seconds().is_positive()),
882        energy_supplied: Kwh::from_decimal(power_supplied_kwh),
883        max_current_supply,
884        max_power_supply,
885    };
886    Ok((metrics, timezone))
887}
888
889fn is_tariff_active(cdr_start: &DateTime<Utc>, tariff: &tariff::v221::Tariff<'_>) -> bool {
890    match (tariff.start_date_time, tariff.end_date_time) {
891        (None, None) => true,
892        (None, Some(end)) => (..end).contains(cdr_start),
893        (Some(start), None) => (start..).contains(cdr_start),
894        (Some(start), Some(end)) => (start..end).contains(cdr_start),
895    }
896}
897
898pub type Caveat<T> = warning::Caveat<T, WarningKind>;
899pub type Result<T> = std::result::Result<T, Error>;
900
901/// Possible errors when generating a charge session.
902#[derive(Debug)]
903pub enum Error {
904    /// The duration of the chargesession is below the minimum allowed.
905    DurationBelowMinimum,
906
907    /// An internal programming error.
908    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
909
910    /// An Error occurred while pricing the generated periods.
911    Price(price::Error),
912
913    /// The `start_date_time` is after the `end_date_time`.
914    StartDateTimeIsAfterEndDateTime,
915
916    /// Converting the `tariff::Versioned` into a structured `tariff::v221::Tariff` caused an
917    /// unrecoverable error.
918    Tariff(warning::Set<WarningKind>),
919}
920
921impl std::error::Error for Error {
922    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
923        match self {
924            Self::DurationBelowMinimum
925            | Self::StartDateTimeIsAfterEndDateTime
926            | Self::Tariff(_) => None,
927            Self::Internal(err) => Some(&**err),
928            Self::Price(err) => Some(err),
929        }
930    }
931}
932
933impl fmt::Display for Error {
934    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
935        match self {
936            Self::DurationBelowMinimum => write!(
937                f,
938                "The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
939            ),
940            Self::Internal(err) => {
941                write!(f, "Internal: {err}")
942            }
943            Self::Price(err) => {
944                write!(f, "{err}")
945            }
946            Self::StartDateTimeIsAfterEndDateTime => {
947                write!(f, "The `start_date_time` is after the `end_date_time`")
948            }
949            Self::Tariff(warning) => {
950                write!(f, "Warnings: {warning:?}")
951            }
952        }
953    }
954}
955
956impl From<warning::Set<WarningKind>> for Error {
957    fn from(warnings: warning::Set<WarningKind>) -> Self {
958        Self::Tariff(warnings)
959    }
960}
961
962impl From<price::Error> for Error {
963    fn from(err: price::Error) -> Self {
964        Error::Price(err)
965    }
966}
967
968/// The config for generating a CDR from a tariff.
969#[derive(Clone)]
970pub struct Config {
971    /// The timezone of the EVSE: The timezone where the chargesession took place.
972    timezone: chrono_tz::Tz,
973
974    /// The start date of the generated CDR.
975    end_date_time: DateTime<Utc>,
976
977    /// The maximum DC current that can be delivered to the battery.
978    max_current_supply: Ampere,
979
980    /// The maximum energy(kWh) the vehicle can accept.
981    ///
982    /// We don't model charging curves for the battery, so we don't care about the existing change of
983    /// the battery.
984    max_energy_battery: Kwh,
985
986    /// The maximum DC power(kw) that can be delivered to the battery.
987    ///
988    /// This is modeled as a DC system as we don't care if the delivery medium is DC or one of the
989    /// various AC forms. We only care what the effective DC power is. The caller of `cdr_from_tariff`
990    /// should convert the delivery medium into a DC kw power by using a power factor.
991    ///
992    /// In practice the maximum power bottleneck is either the EVSE, the cable or the battery itself.
993    /// But whatever the bottleneck is, the caller should work that out and set the maximum expected.
994    max_power_supply: Kw,
995
996    /// The start date of the generated CDR.
997    start_date_time: DateTime<Utc>,
998}
999
1000#[cfg(test)]
1001mod test {
1002    use std::str::FromStr as _;
1003
1004    use chrono::{DateTime, NaiveDateTime, Utc};
1005
1006    use super::DateTimeSpan;
1007
1008    #[track_caller]
1009    pub(super) fn date_time_span(
1010        date_start: &str,
1011        time_start: &str,
1012        date_end: &str,
1013        time_end: &str,
1014    ) -> DateTimeSpan {
1015        DateTimeSpan {
1016            start: datetime_utc(date_start, time_start),
1017            end: datetime_utc(date_end, time_end),
1018        }
1019    }
1020
1021    #[track_caller]
1022    pub(super) fn datetime_utc(date: &str, time: &str) -> DateTime<Utc> {
1023        let s = format!("{date} {time}+00:00");
1024        DateTime::<Utc>::from_str(&s).unwrap()
1025    }
1026
1027    #[track_caller]
1028    pub(super) fn datetime_naive(date: &str, time: &str) -> NaiveDateTime {
1029        let s = format!("{date}T{time}");
1030        NaiveDateTime::from_str(&s).unwrap()
1031    }
1032}
1033
1034#[cfg(test)]
1035mod test_local_to_utc {
1036    use super::{
1037        local_to_utc,
1038        test::{datetime_naive, datetime_utc},
1039    };
1040
1041    #[test]
1042    fn should_convert_from_utc_plus_one() {
1043        let date_time_utc = local_to_utc(
1044            chrono_tz::Tz::Europe__Amsterdam,
1045            datetime_naive("2025-12-18", "11:00:00"),
1046        )
1047        .unwrap();
1048
1049        assert_eq!(date_time_utc, datetime_utc("2025-12-18", "10:00:00"));
1050    }
1051
1052    #[test]
1053    fn should_choose_earliest_date_from_dst_end_fold() {
1054        // The end of DST in NL.
1055        let date_time_utc = local_to_utc(
1056            chrono_tz::Tz::Europe__Amsterdam,
1057            datetime_naive("2025-10-26", "02:59:59"),
1058        )
1059        .unwrap();
1060
1061        assert_eq!(date_time_utc, datetime_utc("2025-10-26", "00:59:59"));
1062    }
1063
1064    #[test]
1065    fn should_return_none_on_dst_begin_gap() {
1066        // The beginning of DST in NL.
1067        let date_time_utc = local_to_utc(
1068            chrono_tz::Tz::Europe__Amsterdam,
1069            datetime_naive("2025-03-30", "02:00:00"),
1070        );
1071
1072        assert_eq!(date_time_utc, None);
1073    }
1074}
1075
1076#[cfg(test)]
1077mod test_periods {
1078    use assert_matches::assert_matches;
1079    use chrono::TimeDelta;
1080    use rust_decimal_macros::dec;
1081
1082    use crate::{
1083        assert_approx_eq, country, currency,
1084        duration::ToHoursDecimal as _,
1085        generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1086        json::FromJson as _,
1087        price, tariff, Ampere, Kw, Kwh, Money, Price,
1088    };
1089
1090    use super::test;
1091
1092    const DATE: &str = "2025-11-10";
1093
1094    fn generate_config() -> generate::Config {
1095        generate::Config {
1096            timezone: chrono_tz::Europe::Amsterdam,
1097            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1098            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1099            max_power_supply: Kw::from(24),
1100            max_energy_battery: Kwh::from(80),
1101            max_current_supply: Ampere::from(4),
1102        }
1103    }
1104
1105    #[track_caller]
1106    fn periods(tariff_json: &str) -> Vec<price::Period> {
1107        let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1108        let (metrics, _tz) = generate::metrics(generate_config()).unwrap();
1109        let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1110            .unwrap()
1111            .ignore_warnings();
1112        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1113        super::charge_periods(&metrics, timeline)
1114    }
1115
1116    #[test]
1117    fn should_generate_periods() {
1118        const TARIFF_JSON: &str = r#"{
1119    "country_code": "DE",
1120    "party_id": "ALL",
1121    "id": "1",
1122    "currency": "EUR",
1123    "type": "REGULAR",
1124    "elements": [
1125        {
1126            "price_components": [{
1127                  "type": "ENERGY",
1128                  "price": 0.50,
1129                  "vat": 20.0,
1130                  "step_size": 1
1131            }]
1132        }
1133    ],
1134    "last_updated": "2018-12-05T12:01:09Z"
1135}
1136"#;
1137
1138        let periods = periods(TARIFF_JSON);
1139        let [period] = periods
1140            .try_into()
1141            .expect("There are no restrictions so there should be one big period");
1142
1143        let price::Period {
1144            start_date_time,
1145            consumed,
1146        } = period;
1147
1148        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1149
1150        let price::Consumed {
1151            duration_charging,
1152            duration_parking,
1153            energy,
1154            current_max,
1155            current_min,
1156            power_max,
1157            power_min,
1158        } = consumed;
1159
1160        assert_eq!(
1161            duration_charging,
1162            Some(TimeDelta::minutes(10)),
1163            "The battery is charged for 10 mins and the plug is pulled"
1164        );
1165        assert_eq!(duration_parking, None, "The battery never fully charges");
1166        assert_approx_eq!(
1167            energy,
1168            Some(Kwh::from(4)),
1169            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1170        );
1171        assert_approx_eq!(
1172            current_max,
1173            None,
1174            "There is no `min_current` or `max_current` restriction defined"
1175        );
1176        assert_approx_eq!(
1177            current_min,
1178            None,
1179            "There is no `min_current` or `max_current` defined"
1180        );
1181        assert_approx_eq!(
1182            power_max,
1183            None,
1184            "There is no `min_power` or `max_power` defined"
1185        );
1186        assert_approx_eq!(
1187            power_min,
1188            None,
1189            "There is no `min_power` or `max_power` defined"
1190        );
1191    }
1192
1193    #[test]
1194    fn should_generate_power() {
1195        const TARIFF_JSON: &str = r#"{
1196    "country_code": "DE",
1197    "party_id": "ALL",
1198    "id": "1",
1199    "currency": "EUR",
1200    "type": "REGULAR",
1201    "elements": [
1202        {
1203            "price_components": [{
1204              "type": "ENERGY",
1205              "price": 0.60,
1206              "vat": 20.0,
1207              "step_size": 1
1208            }],
1209            "restrictions": {
1210              "max_power": 16.00
1211            }
1212        },
1213        {
1214            "price_components": [{
1215              "type": "ENERGY",
1216              "price": 0.70,
1217              "vat": 20.0,
1218              "step_size": 1
1219            }],
1220            "restrictions": {
1221              "max_power": 32.00
1222            }
1223        },
1224        {
1225            "price_components": [{
1226                  "type": "ENERGY",
1227                  "price": 0.50,
1228                  "vat": 20.0,
1229                  "step_size": 1
1230            }]
1231        }
1232    ],
1233    "last_updated": "2018-12-05T12:01:09Z"
1234}
1235"#;
1236
1237        let config = generate_config();
1238        let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1239        let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1240        let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1241            .unwrap()
1242            .ignore_warnings();
1243        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1244        let periods = super::charge_periods(&metrics, timeline);
1245
1246        // let periods = periods(TARIFF_JSON);
1247        let [ref period] = periods
1248            .try_into()
1249            .expect("There are no restrictions so there should be one big period");
1250
1251        let price::Period {
1252            start_date_time,
1253            consumed,
1254        } = period;
1255
1256        assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1257
1258        let price::Consumed {
1259            duration_charging,
1260            duration_parking,
1261            energy,
1262            current_max,
1263            current_min,
1264            power_max,
1265            power_min,
1266        } = consumed;
1267
1268        assert_eq!(
1269            *duration_charging,
1270            Some(TimeDelta::minutes(10)),
1271            "The battery is charged for 10 mins and the plug is pulled"
1272        );
1273        assert_eq!(*duration_parking, None, "The battery never fully charges");
1274        assert_approx_eq!(
1275            energy,
1276            Some(Kwh::from(4)),
1277            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1278        );
1279        assert_approx_eq!(
1280            current_max,
1281            None,
1282            "There is no `min_current` or `max_current` restriction defined"
1283        );
1284        assert_approx_eq!(
1285            current_min,
1286            None,
1287            "There is no `min_current` or `max_current` defined"
1288        );
1289        assert_approx_eq!(
1290            power_max,
1291            Some(Kw::from(24)),
1292            "There is a `max_power` defined"
1293        );
1294        assert_approx_eq!(
1295            power_min,
1296            Some(Kw::from(24)),
1297            "There is a `max_power` defined"
1298        );
1299        let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1300        let (report, warnings) = report.into_parts();
1301        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1302
1303        let PartialCdr {
1304            country_code,
1305            party_id,
1306            start_date_time,
1307            end_date_time,
1308            currency,
1309            total_energy,
1310            total_time,
1311            total_parking_time,
1312            total_cost,
1313            total_energy_cost,
1314            total_fixed_cost,
1315            total_parking_cost,
1316            total_time_cost,
1317            charging_periods,
1318        } = report.partial_cdr;
1319
1320        assert_eq!(country_code, Some(country::Code::De));
1321        assert_eq!(party_id.as_deref(), Some("ALL"));
1322        assert_eq!(currency, currency::Code::Eur);
1323        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1324        assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1325
1326        assert_approx_eq!(
1327            total_cost,
1328            Some(Price {
1329                excl_vat: Money::from(2.80),
1330                incl_vat: Some(Money::from(3.36))
1331            }),
1332            "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1333        );
1334        assert_eq!(
1335            total_time,
1336            Some(TimeDelta::minutes(10)),
1337            "The charging session is 10 min and is stopped before the battery is fully charged."
1338        );
1339        assert_eq!(
1340            total_parking_time, None,
1341            "There is no parking time since the battery never fully charged."
1342        );
1343        assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1344        assert_approx_eq!(
1345            total_energy_cost,
1346            Some(Price {
1347                excl_vat: Money::from(2.80),
1348                incl_vat: Some(Money::from(3.36))
1349            }),
1350            "The cost per KwH is 70 cents and the VAT is 20%."
1351        );
1352        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1353        assert_eq!(
1354            total_parking_cost, None,
1355            "There is no parking cost as there is no parking time."
1356        );
1357        assert_eq!(
1358            total_time_cost, None,
1359            "There are no time costs defined in the tariff."
1360        );
1361
1362        let [period] = charging_periods
1363            .try_into()
1364            .expect("There should be one period.");
1365
1366        let ChargingPeriod {
1367            start_date_time,
1368            dimensions,
1369            tariff_id,
1370        } = period;
1371
1372        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1373        assert_eq!(tariff_id.as_deref(), Some("1"));
1374
1375        let [energy, time] = dimensions
1376            .try_into()
1377            .expect("There should be an energy dimension");
1378
1379        let Dimension {
1380            dimension_type,
1381            volume,
1382        } = energy;
1383
1384        assert_eq!(dimension_type, DimensionType::Energy);
1385        assert_approx_eq!(volume, dec!(4.0));
1386
1387        let Dimension {
1388            dimension_type,
1389            volume,
1390        } = time;
1391
1392        assert_eq!(dimension_type, DimensionType::Time);
1393        assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1394    }
1395
1396    #[test]
1397    fn should_generate_current() {
1398        const TARIFF_JSON: &str = r#"{
1399    "country_code": "DE",
1400    "party_id": "ALL",
1401    "id": "1",
1402    "currency": "EUR",
1403    "type": "REGULAR",
1404    "elements": [
1405        {
1406            "price_components": [{
1407              "type": "ENERGY",
1408              "price": 0.60,
1409              "vat": 20.0,
1410              "step_size": 1
1411            }],
1412            "restrictions": {
1413              "max_current": 2
1414            }
1415        },
1416        {
1417            "price_components": [{
1418              "type": "ENERGY",
1419              "price": 0.70,
1420              "vat": 20.0,
1421              "step_size": 1
1422            }],
1423            "restrictions": {
1424              "max_current": 4
1425            }
1426        },
1427        {
1428            "price_components": [{
1429                  "type": "ENERGY",
1430                  "price": 0.50,
1431                  "vat": 20.0,
1432                  "step_size": 1
1433            }]
1434        }
1435    ],
1436    "last_updated": "2018-12-05T12:01:09Z"
1437}
1438"#;
1439
1440        let config = generate_config();
1441        let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1442        let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1443        let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1444            .unwrap()
1445            .ignore_warnings();
1446        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1447        let periods = super::charge_periods(&metrics, timeline);
1448
1449        // let periods = periods(TARIFF_JSON);
1450        let [ref period] = periods
1451            .try_into()
1452            .expect("There are no restrictions so there should be one big period");
1453
1454        let price::Period {
1455            start_date_time,
1456            consumed,
1457        } = period;
1458
1459        assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1460
1461        let price::Consumed {
1462            duration_charging,
1463            duration_parking,
1464            current_max,
1465            current_min,
1466            energy,
1467            power_max,
1468            power_min,
1469        } = consumed;
1470
1471        assert_eq!(
1472            *duration_charging,
1473            Some(TimeDelta::minutes(10)),
1474            "The battery is charged for 10 mins and the plug is pulled"
1475        );
1476        assert_eq!(*duration_parking, None, "The battery never fully charges");
1477        assert_approx_eq!(
1478            energy,
1479            Some(Kwh::from(4)),
1480            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1481        );
1482        assert_approx_eq!(
1483            current_max,
1484            Some(Ampere::from(4)),
1485            "There is a `max_current` restriction defined"
1486        );
1487        assert_approx_eq!(
1488            current_min,
1489            Some(Ampere::from(4)),
1490            "There is a `max_current` restriction defined"
1491        );
1492        assert_approx_eq!(
1493            power_max,
1494            None,
1495            "There is no `min_power` or `max_power` defined"
1496        );
1497        assert_approx_eq!(
1498            power_min,
1499            None,
1500            "There is no `min_power` or `max_power` defined"
1501        );
1502        let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1503        let (report, warnings) = report.into_parts();
1504        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1505
1506        let PartialCdr {
1507            country_code,
1508            party_id,
1509            start_date_time,
1510            end_date_time,
1511            currency,
1512            total_energy,
1513            total_time,
1514            total_parking_time,
1515            total_cost,
1516            total_energy_cost,
1517            total_fixed_cost,
1518            total_parking_cost,
1519            total_time_cost,
1520            charging_periods,
1521        } = report.partial_cdr;
1522
1523        assert_eq!(country_code, Some(country::Code::De));
1524        assert_eq!(party_id.as_deref(), Some("ALL"));
1525        assert_eq!(currency, currency::Code::Eur);
1526        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1527        assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1528
1529        assert_approx_eq!(
1530            total_cost,
1531            Some(Price {
1532                excl_vat: Money::from(2.00),
1533                incl_vat: Some(Money::from(2.40))
1534            }),
1535            "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1536        );
1537        assert_eq!(
1538            total_time,
1539            Some(TimeDelta::minutes(10)),
1540            "The charging session is 10 min and is stopped before the battery is fully charged."
1541        );
1542        assert_eq!(
1543            total_parking_time, None,
1544            "There is no parking time since the battery never fully charged."
1545        );
1546        assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1547        assert_approx_eq!(
1548            total_energy_cost,
1549            Some(Price {
1550                excl_vat: Money::from(2.00),
1551                incl_vat: Some(Money::from(2.40))
1552            }),
1553            "The cost per KwH is 70 cents and the VAT is 20%."
1554        );
1555        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1556        assert_eq!(
1557            total_parking_cost, None,
1558            "There is no parking cost as there is no parking time."
1559        );
1560        assert_eq!(
1561            total_time_cost, None,
1562            "There are no time costs defined in the tariff."
1563        );
1564
1565        let [period] = charging_periods
1566            .try_into()
1567            .expect("There should be one period.");
1568
1569        let ChargingPeriod {
1570            start_date_time,
1571            dimensions,
1572            tariff_id,
1573        } = period;
1574
1575        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1576        assert_eq!(tariff_id.as_deref(), Some("1"));
1577
1578        let [energy, time] = dimensions
1579            .try_into()
1580            .expect("There should be an energy dimension");
1581
1582        let Dimension {
1583            dimension_type,
1584            volume,
1585        } = energy;
1586
1587        assert_eq!(dimension_type, DimensionType::Energy);
1588        assert_approx_eq!(volume, dec!(4.0));
1589
1590        let Dimension {
1591            dimension_type,
1592            volume,
1593        } = time;
1594
1595        assert_eq!(dimension_type, DimensionType::Time);
1596        assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1597    }
1598}
1599
1600#[cfg(test)]
1601mod test_generate {
1602    use assert_matches::assert_matches;
1603
1604    use crate::{
1605        generate::{self},
1606        tariff,
1607    };
1608
1609    use super::test;
1610
1611    const DATE: &str = "2025-11-10";
1612
1613    #[test]
1614    fn should_warn_no_elements() {
1615        const TARIFF_JSON: &str = r#"{
1616    "country_code": "DE",
1617    "party_id": "ALL",
1618    "id": "1",
1619    "currency": "EUR",
1620    "type": "REGULAR",
1621    "elements": [],
1622    "last_updated": "2018-12-05T12:01:09Z"
1623}
1624"#;
1625
1626        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1627        let config = generate::Config {
1628            timezone: chrono_tz::Europe::Amsterdam,
1629            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1630            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1631            max_power_supply: 12.into(),
1632            max_energy_battery: 80.into(),
1633            max_current_supply: 2.into(),
1634        };
1635        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1636        let warnings = assert_matches!(err, generate::Error::Tariff(w) => w);
1637        assert_matches!(
1638            warnings.into_kind_vec().as_slice(),
1639            [generate::WarningKind::NoElements]
1640        );
1641    }
1642}
1643
1644#[cfg(test)]
1645mod test_generate_from_single_elem_tariff {
1646    use assert_matches::assert_matches;
1647    use chrono::TimeDelta;
1648
1649    use crate::{
1650        assert_approx_eq,
1651        generate::{self, PartialCdr},
1652        tariff, Kwh, Money, Price,
1653    };
1654
1655    use super::test;
1656
1657    const DATE: &str = "2025-11-10";
1658    const TARIFF_JSON: &str = r#"{
1659    "country_code": "DE",
1660    "party_id": "ALL",
1661    "id": "1",
1662    "currency": "EUR",
1663    "type": "REGULAR",
1664    "elements": [
1665        {
1666            "price_components": [{
1667                  "type": "ENERGY",
1668                  "price": 0.50,
1669                  "vat": 20.0,
1670                  "step_size": 1
1671            }]
1672        }
1673    ],
1674    "last_updated": "2018-12-05T12:01:09Z"
1675}
1676"#;
1677
1678    fn generate_config() -> generate::Config {
1679        generate::Config {
1680            timezone: chrono_tz::Europe::Amsterdam,
1681            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1682            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1683            max_power_supply: 12.into(),
1684            max_energy_battery: 80.into(),
1685            max_current_supply: 2.into(),
1686        }
1687    }
1688
1689    #[track_caller]
1690    fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1691        let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1692        generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1693    }
1694
1695    #[test]
1696    fn should_warn_duration_below_min() {
1697        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1698        let config = generate::Config {
1699            timezone: chrono_tz::Europe::Amsterdam,
1700            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1701            end_date_time: test::datetime_utc(DATE, "15:03:12"),
1702            max_power_supply: 12.into(),
1703            max_energy_battery: 80.into(),
1704            max_current_supply: 2.into(),
1705        };
1706        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1707        assert_matches!(err, generate::Error::DurationBelowMinimum);
1708    }
1709
1710    #[test]
1711    fn should_warn_end_before_start() {
1712        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1713        let config = generate::Config {
1714            timezone: chrono_tz::Europe::Amsterdam,
1715            start_date_time: test::datetime_utc(DATE, "15:12:12"),
1716            end_date_time: test::datetime_utc(DATE, "15:02:12"),
1717            max_power_supply: 12.into(),
1718            max_energy_battery: 80.into(),
1719            max_current_supply: 2.into(),
1720        };
1721        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1722        assert_matches!(err, generate::Error::StartDateTimeIsAfterEndDateTime);
1723    }
1724
1725    #[test]
1726    fn should_generate_energy_for_ten_minutes() {
1727        let report = generate(TARIFF_JSON);
1728        let (report, warnings) = report.into_parts();
1729        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1730
1731        let PartialCdr {
1732            country_code: _,
1733            party_id: _,
1734            start_date_time: _,
1735            end_date_time: _,
1736            currency: _,
1737            total_energy,
1738            total_time,
1739            total_parking_time,
1740            total_cost,
1741            total_energy_cost,
1742            total_fixed_cost,
1743            total_parking_cost,
1744            total_time_cost,
1745            charging_periods: _,
1746        } = report.partial_cdr;
1747
1748        assert_approx_eq!(
1749            total_cost,
1750            Some(Price {
1751                excl_vat: Money::from(1),
1752                incl_vat: Some(Money::from(1.2))
1753            })
1754        );
1755        assert_eq!(
1756            total_time,
1757            Some(TimeDelta::minutes(10)),
1758            "The charging session is 10 min and is stopped before the battery is fully charged."
1759        );
1760        assert_eq!(
1761            total_parking_time, None,
1762            "There is no parking time since the battery never fully charged."
1763        );
1764        assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1765        assert_approx_eq!(
1766            total_energy_cost,
1767            Some(Price {
1768                excl_vat: Money::from(1),
1769                incl_vat: Some(Money::from(1.2))
1770            }),
1771            "The cost per KwH is 50 cents and the VAT is 20%."
1772        );
1773        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1774        assert_eq!(
1775            total_parking_cost, None,
1776            "There is no parking cost as there is no parking time."
1777        );
1778        assert_eq!(
1779            total_time_cost, None,
1780            "There are no time costs defined in the tariff."
1781        );
1782    }
1783}
1784
1785#[cfg(test)]
1786mod test_clamp_date_time_span {
1787    use super::{clamp_date_time_span, DateTimeSpan};
1788
1789    use super::test::{date_time_span, datetime_utc};
1790
1791    #[test]
1792    fn should_not_clamp_if_start_and_end_are_none() {
1793        let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1794
1795        let out_span = clamp_date_time_span(None, None, in_span.clone());
1796
1797        assert_eq!(in_span, out_span);
1798    }
1799
1800    #[test]
1801    fn should_not_clamp_if_start_and_end_are_contained() {
1802        let start = datetime_utc("2025-11-01", "12:02:00");
1803        let end = datetime_utc("2025-11-10", "14:00:00");
1804        let in_span = DateTimeSpan { start, end };
1805        let min_date = datetime_utc("2025-11-01", "11:00:00");
1806        let max_date = datetime_utc("2025-11-10", "15:00:00");
1807
1808        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1809
1810        assert_eq!(in_span, out_span);
1811    }
1812
1813    #[test]
1814    fn should_clamp_if_span_start_earlier() {
1815        let start = datetime_utc("2025-11-01", "12:02:00");
1816        let end = datetime_utc("2025-11-10", "14:00:00");
1817        let in_span = DateTimeSpan { start, end };
1818        let min_date = datetime_utc("2025-11-02", "00:00:00");
1819        let max_date = datetime_utc("2025-11-10", "23:00:00");
1820
1821        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1822
1823        assert_eq!(out_span.start, min_date);
1824        assert_eq!(out_span.end, end);
1825    }
1826
1827    #[test]
1828    fn should_clamp_if_end_later() {
1829        let start = datetime_utc("2025-11-01", "12:02:00");
1830        let end = datetime_utc("2025-11-10", "14:00:00");
1831        let in_span = DateTimeSpan { start, end };
1832        let min_date = datetime_utc("2025-11-01", "00:00:00");
1833        let max_date = datetime_utc("2025-11-09", "23:00:00");
1834
1835        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1836
1837        assert_eq!(out_span.start, start);
1838        assert_eq!(out_span.end, max_date);
1839    }
1840}
1841
1842#[cfg(test)]
1843mod test_gen_time_events {
1844    use assert_matches::assert_matches;
1845    use chrono::TimeDelta;
1846
1847    use super::{generate_time_events, v2x::TimeRestrictions};
1848
1849    use super::test::date_time_span;
1850
1851    #[test]
1852    fn should_emit_no_events_before_start_time() {
1853        // The chargesession takes place before the `start_time`
1854        let events = generate_time_events(
1855            chrono_tz::Tz::Europe__Amsterdam,
1856            date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1857            TimeRestrictions {
1858                start_time: Some("15:00".parse().unwrap()),
1859                ..TimeRestrictions::default()
1860            },
1861        );
1862
1863        assert_matches!(events.as_slice(), []);
1864    }
1865
1866    #[test]
1867    fn should_emit_no_events_finishes_at_start_time_pricisely() {
1868        // The chargesession takes place before the `start_time`
1869        let events = generate_time_events(
1870            chrono_tz::Tz::Europe__Amsterdam,
1871            date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1872            TimeRestrictions {
1873                start_time: Some("15:00".parse().unwrap()),
1874                ..TimeRestrictions::default()
1875            },
1876        );
1877
1878        assert_matches!(events.as_slice(), []);
1879    }
1880
1881    #[test]
1882    fn should_emit_one_event_precise_overlap_with_start_time() {
1883        // The chargesession starts precisely when the `start_time` generates an event.
1884        let events = generate_time_events(
1885            chrono_tz::Tz::Europe__Amsterdam,
1886            date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1887            TimeRestrictions {
1888                start_time: Some("15:00".parse().unwrap()),
1889                ..TimeRestrictions::default()
1890            },
1891        );
1892
1893        let [event] = events.try_into().unwrap();
1894        assert_eq!(event.duration_from_start, TimeDelta::zero());
1895    }
1896
1897    #[test]
1898    fn should_emit_one_event_hour_before_start_time() {
1899        // The chargesession starts an hour before the `start_time` generates an event.
1900        let events = generate_time_events(
1901            chrono_tz::Tz::Europe__Amsterdam,
1902            date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1903            TimeRestrictions {
1904                start_time: Some("15:00".parse().unwrap()),
1905                ..TimeRestrictions::default()
1906            },
1907        );
1908
1909        let [event] = events.try_into().unwrap();
1910        assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1911    }
1912
1913    #[test]
1914    fn should_emit_one_event_almost_full_day() {
1915        // The chargesession last from precisely the `start_time` of one day
1916        // until just before the `start_time` of the next.
1917        let events = generate_time_events(
1918            chrono_tz::Tz::Europe__Amsterdam,
1919            date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1920            TimeRestrictions {
1921                start_time: Some("15:00".parse().unwrap()),
1922                ..TimeRestrictions::default()
1923            },
1924        );
1925
1926        let [event] = events.try_into().unwrap();
1927        assert_eq!(event.duration_from_start, TimeDelta::zero());
1928    }
1929
1930    #[test]
1931    fn should_emit_two_events_full_day_precisely() {
1932        let events = generate_time_events(
1933            chrono_tz::Tz::Europe__Amsterdam,
1934            date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1935            TimeRestrictions {
1936                start_time: Some("15:00".parse().unwrap()),
1937                ..TimeRestrictions::default()
1938            },
1939        );
1940
1941        let [event_0, event_1] = events.try_into().unwrap();
1942        assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1943        assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1944    }
1945
1946    #[test]
1947    fn should_emit_two_events_full_day_with_hour_margin() {
1948        let events = generate_time_events(
1949            chrono_tz::Tz::Europe__Amsterdam,
1950            date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1951            TimeRestrictions {
1952                start_time: Some("15:00".parse().unwrap()),
1953                ..TimeRestrictions::default()
1954            },
1955        );
1956
1957        let [event_0, event_1] = events.try_into().unwrap();
1958        assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1959        assert_eq!(
1960            event_1.duration_from_start,
1961            TimeDelta::days(1) + TimeDelta::hours(1)
1962        );
1963    }
1964}