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_kw: max_power_supply,
829        max_energy_battery_kwh: max_energy_battery,
830        max_current_supply_amp: 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: Ampere::from_decimal(max_current_supply),
884        max_power_supply: Kw::from_decimal(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    pub timezone: chrono_tz::Tz,
973
974    /// The start date of the generated CDR.
975    pub end_date_time: DateTime<Utc>,
976
977    /// The maximum DC current that can be delivered to the battery.
978    pub max_current_supply_amp: Decimal,
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    pub max_energy_battery_kwh: Decimal,
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    pub max_power_supply_kw: Decimal,
995
996    /// The start date of the generated CDR.
997    pub 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::Decimal;
1081    use rust_decimal_macros::dec;
1082
1083    use crate::{
1084        assert_approx_eq, country, currency,
1085        duration::ToHoursDecimal as _,
1086        generate::{self, ChargingPeriod, Dimension, DimensionType, PartialCdr},
1087        json::FromJson as _,
1088        price, tariff, Ampere, Kw, Kwh, Money, Price,
1089    };
1090
1091    use super::test;
1092
1093    const DATE: &str = "2025-11-10";
1094
1095    fn generate_config() -> generate::Config {
1096        generate::Config {
1097            timezone: chrono_tz::Europe::Amsterdam,
1098            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1099            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1100            max_power_supply_kw: Decimal::from(24),
1101            max_energy_battery_kwh: Decimal::from(80),
1102            max_current_supply_amp: Decimal::from(4),
1103        }
1104    }
1105
1106    #[track_caller]
1107    fn periods(tariff_json: &str) -> Vec<price::Period> {
1108        let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1109        let (metrics, _tz) = generate::metrics(generate_config()).unwrap();
1110        let tariff = tariff::v221::Tariff::from_json(tariff.as_element())
1111            .unwrap()
1112            .ignore_warnings();
1113        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1114        super::charge_periods(&metrics, timeline)
1115    }
1116
1117    #[test]
1118    fn should_generate_periods() {
1119        const TARIFF_JSON: &str = r#"{
1120    "country_code": "DE",
1121    "party_id": "ALL",
1122    "id": "1",
1123    "currency": "EUR",
1124    "type": "REGULAR",
1125    "elements": [
1126        {
1127            "price_components": [{
1128                  "type": "ENERGY",
1129                  "price": 0.50,
1130                  "vat": 20.0,
1131                  "step_size": 1
1132            }]
1133        }
1134    ],
1135    "last_updated": "2018-12-05T12:01:09Z"
1136}
1137"#;
1138
1139        let periods = periods(TARIFF_JSON);
1140        let [period] = periods
1141            .try_into()
1142            .expect("There are no restrictions so there should be one big period");
1143
1144        let price::Period {
1145            start_date_time,
1146            consumed,
1147        } = period;
1148
1149        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1150
1151        let price::Consumed {
1152            duration_charging,
1153            duration_parking,
1154            energy,
1155            current_max,
1156            current_min,
1157            power_max,
1158            power_min,
1159        } = consumed;
1160
1161        assert_eq!(
1162            duration_charging,
1163            Some(TimeDelta::minutes(10)),
1164            "The battery is charged for 10 mins and the plug is pulled"
1165        );
1166        assert_eq!(duration_parking, None, "The battery never fully charges");
1167        assert_approx_eq!(
1168            energy,
1169            Some(Kwh::from(4)),
1170            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1171        );
1172        assert_approx_eq!(
1173            current_max,
1174            None,
1175            "There is no `min_current` or `max_current` restriction defined"
1176        );
1177        assert_approx_eq!(
1178            current_min,
1179            None,
1180            "There is no `min_current` or `max_current` defined"
1181        );
1182        assert_approx_eq!(
1183            power_max,
1184            None,
1185            "There is no `min_power` or `max_power` defined"
1186        );
1187        assert_approx_eq!(
1188            power_min,
1189            None,
1190            "There is no `min_power` or `max_power` defined"
1191        );
1192    }
1193
1194    #[test]
1195    fn should_generate_power() {
1196        const TARIFF_JSON: &str = r#"{
1197    "country_code": "DE",
1198    "party_id": "ALL",
1199    "id": "1",
1200    "currency": "EUR",
1201    "type": "REGULAR",
1202    "elements": [
1203        {
1204            "price_components": [{
1205              "type": "ENERGY",
1206              "price": 0.60,
1207              "vat": 20.0,
1208              "step_size": 1
1209            }],
1210            "restrictions": {
1211              "max_power": 16.00
1212            }
1213        },
1214        {
1215            "price_components": [{
1216              "type": "ENERGY",
1217              "price": 0.70,
1218              "vat": 20.0,
1219              "step_size": 1
1220            }],
1221            "restrictions": {
1222              "max_power": 32.00
1223            }
1224        },
1225        {
1226            "price_components": [{
1227                  "type": "ENERGY",
1228                  "price": 0.50,
1229                  "vat": 20.0,
1230                  "step_size": 1
1231            }]
1232        }
1233    ],
1234    "last_updated": "2018-12-05T12:01:09Z"
1235}
1236"#;
1237
1238        let config = generate_config();
1239        let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1240        let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1241        let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1242            .unwrap()
1243            .ignore_warnings();
1244        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1245        let periods = super::charge_periods(&metrics, timeline);
1246
1247        // let periods = periods(TARIFF_JSON);
1248        let [ref period] = periods
1249            .try_into()
1250            .expect("There are no restrictions so there should be one big period");
1251
1252        let price::Period {
1253            start_date_time,
1254            consumed,
1255        } = period;
1256
1257        assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1258
1259        let price::Consumed {
1260            duration_charging,
1261            duration_parking,
1262            energy,
1263            current_max,
1264            current_min,
1265            power_max,
1266            power_min,
1267        } = consumed;
1268
1269        assert_eq!(
1270            *duration_charging,
1271            Some(TimeDelta::minutes(10)),
1272            "The battery is charged for 10 mins and the plug is pulled"
1273        );
1274        assert_eq!(*duration_parking, None, "The battery never fully charges");
1275        assert_approx_eq!(
1276            energy,
1277            Some(Kwh::from(4)),
1278            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1279        );
1280        assert_approx_eq!(
1281            current_max,
1282            None,
1283            "There is no `min_current` or `max_current` restriction defined"
1284        );
1285        assert_approx_eq!(
1286            current_min,
1287            None,
1288            "There is no `min_current` or `max_current` defined"
1289        );
1290        assert_approx_eq!(
1291            power_max,
1292            Some(Kw::from(24)),
1293            "There is a `max_power` defined"
1294        );
1295        assert_approx_eq!(
1296            power_min,
1297            Some(Kw::from(24)),
1298            "There is a `max_power` defined"
1299        );
1300        let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1301        let (report, warnings) = report.into_parts();
1302        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1303
1304        let PartialCdr {
1305            country_code,
1306            party_id,
1307            start_date_time,
1308            end_date_time,
1309            currency,
1310            total_energy,
1311            total_time,
1312            total_parking_time,
1313            total_cost,
1314            total_energy_cost,
1315            total_fixed_cost,
1316            total_parking_cost,
1317            total_time_cost,
1318            charging_periods,
1319        } = report.partial_cdr;
1320
1321        assert_eq!(country_code, Some(country::Code::De));
1322        assert_eq!(party_id.as_deref(), Some("ALL"));
1323        assert_eq!(currency, currency::Code::Eur);
1324        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1325        assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1326
1327        assert_approx_eq!(
1328            total_cost,
1329            Some(Price {
1330                excl_vat: Money::from(2.80),
1331                incl_vat: Some(Money::from(3.36))
1332            }),
1333            "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1334        );
1335        assert_eq!(
1336            total_time,
1337            Some(TimeDelta::minutes(10)),
1338            "The charging session is 10 min and is stopped before the battery is fully charged."
1339        );
1340        assert_eq!(
1341            total_parking_time, None,
1342            "There is no parking time since the battery never fully charged."
1343        );
1344        assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1345        assert_approx_eq!(
1346            total_energy_cost,
1347            Some(Price {
1348                excl_vat: Money::from(2.80),
1349                incl_vat: Some(Money::from(3.36))
1350            }),
1351            "The cost per KwH is 70 cents and the VAT is 20%."
1352        );
1353        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1354        assert_eq!(
1355            total_parking_cost, None,
1356            "There is no parking cost as there is no parking time."
1357        );
1358        assert_eq!(
1359            total_time_cost, None,
1360            "There are no time costs defined in the tariff."
1361        );
1362
1363        let [period] = charging_periods
1364            .try_into()
1365            .expect("There should be one period.");
1366
1367        let ChargingPeriod {
1368            start_date_time,
1369            dimensions,
1370            tariff_id,
1371        } = period;
1372
1373        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1374        assert_eq!(tariff_id.as_deref(), Some("1"));
1375
1376        let [energy, time] = dimensions
1377            .try_into()
1378            .expect("There should be an energy dimension");
1379
1380        let Dimension {
1381            dimension_type,
1382            volume,
1383        } = energy;
1384
1385        assert_eq!(dimension_type, DimensionType::Energy);
1386        assert_approx_eq!(volume, dec!(4.0));
1387
1388        let Dimension {
1389            dimension_type,
1390            volume,
1391        } = time;
1392
1393        assert_eq!(dimension_type, DimensionType::Time);
1394        assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1395    }
1396
1397    #[test]
1398    fn should_generate_current() {
1399        const TARIFF_JSON: &str = r#"{
1400    "country_code": "DE",
1401    "party_id": "ALL",
1402    "id": "1",
1403    "currency": "EUR",
1404    "type": "REGULAR",
1405    "elements": [
1406        {
1407            "price_components": [{
1408              "type": "ENERGY",
1409              "price": 0.60,
1410              "vat": 20.0,
1411              "step_size": 1
1412            }],
1413            "restrictions": {
1414              "max_current": 2
1415            }
1416        },
1417        {
1418            "price_components": [{
1419              "type": "ENERGY",
1420              "price": 0.70,
1421              "vat": 20.0,
1422              "step_size": 1
1423            }],
1424            "restrictions": {
1425              "max_current": 4
1426            }
1427        },
1428        {
1429            "price_components": [{
1430                  "type": "ENERGY",
1431                  "price": 0.50,
1432                  "vat": 20.0,
1433                  "step_size": 1
1434            }]
1435        }
1436    ],
1437    "last_updated": "2018-12-05T12:01:09Z"
1438}
1439"#;
1440
1441        let config = generate_config();
1442        let tariff_elem = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1443        let (metrics, _tz) = generate::metrics(config.clone()).unwrap();
1444        let tariff = tariff::v221::Tariff::from_json(tariff_elem.as_element())
1445            .unwrap()
1446            .ignore_warnings();
1447        let timeline = super::timeline(chrono_tz::Tz::Europe__Amsterdam, &metrics, &tariff);
1448        let periods = super::charge_periods(&metrics, timeline);
1449
1450        // let periods = periods(TARIFF_JSON);
1451        let [ref period] = periods
1452            .try_into()
1453            .expect("There are no restrictions so there should be one big period");
1454
1455        let price::Period {
1456            start_date_time,
1457            consumed,
1458        } = period;
1459
1460        assert_eq!(*start_date_time, test::datetime_utc(DATE, "15:02:12"));
1461
1462        let price::Consumed {
1463            duration_charging,
1464            duration_parking,
1465            current_max,
1466            current_min,
1467            energy,
1468            power_max,
1469            power_min,
1470        } = consumed;
1471
1472        assert_eq!(
1473            *duration_charging,
1474            Some(TimeDelta::minutes(10)),
1475            "The battery is charged for 10 mins and the plug is pulled"
1476        );
1477        assert_eq!(*duration_parking, None, "The battery never fully charges");
1478        assert_approx_eq!(
1479            energy,
1480            Some(Kwh::from(4)),
1481            "The energy supplied is 24 Kwh from a session duration of 10 Mins (0.1666 Hours), so 4 Kwh should be consumed"
1482        );
1483        assert_approx_eq!(
1484            current_max,
1485            Some(Ampere::from(4)),
1486            "There is a `max_current` restriction defined"
1487        );
1488        assert_approx_eq!(
1489            current_min,
1490            Some(Ampere::from(4)),
1491            "There is a `max_current` restriction defined"
1492        );
1493        assert_approx_eq!(
1494            power_max,
1495            None,
1496            "There is no `min_power` or `max_power` defined"
1497        );
1498        assert_approx_eq!(
1499            power_min,
1500            None,
1501            "There is no `min_power` or `max_power` defined"
1502        );
1503        let report = generate::cdr_from_tariff(&tariff_elem, config).unwrap();
1504        let (report, warnings) = report.into_parts();
1505        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1506
1507        let PartialCdr {
1508            country_code,
1509            party_id,
1510            start_date_time,
1511            end_date_time,
1512            currency,
1513            total_energy,
1514            total_time,
1515            total_parking_time,
1516            total_cost,
1517            total_energy_cost,
1518            total_fixed_cost,
1519            total_parking_cost,
1520            total_time_cost,
1521            charging_periods,
1522        } = report.partial_cdr;
1523
1524        assert_eq!(country_code, Some(country::Code::De));
1525        assert_eq!(party_id.as_deref(), Some("ALL"));
1526        assert_eq!(currency, currency::Code::Eur);
1527        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1528        assert_eq!(end_date_time, test::datetime_utc(DATE, "15:12:12"));
1529
1530        assert_approx_eq!(
1531            total_cost,
1532            Some(Price {
1533                excl_vat: Money::from(2.00),
1534                incl_vat: Some(Money::from(2.40))
1535            }),
1536            "The power input is 24 Kw and the second tariff element with a price per KwH or 0.70 should be used."
1537        );
1538        assert_eq!(
1539            total_time,
1540            Some(TimeDelta::minutes(10)),
1541            "The charging session is 10 min and is stopped before the battery is fully charged."
1542        );
1543        assert_eq!(
1544            total_parking_time, None,
1545            "There is no parking time since the battery never fully charged."
1546        );
1547        assert_approx_eq!(total_energy, Some(Kwh::from(4)));
1548        assert_approx_eq!(
1549            total_energy_cost,
1550            Some(Price {
1551                excl_vat: Money::from(2.00),
1552                incl_vat: Some(Money::from(2.40))
1553            }),
1554            "The cost per KwH is 70 cents and the VAT is 20%."
1555        );
1556        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1557        assert_eq!(
1558            total_parking_cost, None,
1559            "There is no parking cost as there is no parking time."
1560        );
1561        assert_eq!(
1562            total_time_cost, None,
1563            "There are no time costs defined in the tariff."
1564        );
1565
1566        let [period] = charging_periods
1567            .try_into()
1568            .expect("There should be one period.");
1569
1570        let ChargingPeriod {
1571            start_date_time,
1572            dimensions,
1573            tariff_id,
1574        } = period;
1575
1576        assert_eq!(start_date_time, test::datetime_utc(DATE, "15:02:12"));
1577        assert_eq!(tariff_id.as_deref(), Some("1"));
1578
1579        let [energy, time] = dimensions
1580            .try_into()
1581            .expect("There should be an energy dimension");
1582
1583        let Dimension {
1584            dimension_type,
1585            volume,
1586        } = energy;
1587
1588        assert_eq!(dimension_type, DimensionType::Energy);
1589        assert_approx_eq!(volume, dec!(4.0));
1590
1591        let Dimension {
1592            dimension_type,
1593            volume,
1594        } = time;
1595
1596        assert_eq!(dimension_type, DimensionType::Time);
1597        assert_approx_eq!(volume, TimeDelta::minutes(10).to_hours_dec());
1598    }
1599}
1600
1601#[cfg(test)]
1602mod test_generate {
1603    use assert_matches::assert_matches;
1604
1605    use crate::{
1606        generate::{self},
1607        tariff,
1608    };
1609
1610    use super::test;
1611
1612    const DATE: &str = "2025-11-10";
1613
1614    #[test]
1615    fn should_warn_no_elements() {
1616        const TARIFF_JSON: &str = r#"{
1617    "country_code": "DE",
1618    "party_id": "ALL",
1619    "id": "1",
1620    "currency": "EUR",
1621    "type": "REGULAR",
1622    "elements": [],
1623    "last_updated": "2018-12-05T12:01:09Z"
1624}
1625"#;
1626
1627        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1628        let config = generate::Config {
1629            timezone: chrono_tz::Europe::Amsterdam,
1630            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1631            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1632            max_power_supply_kw: 12.into(),
1633            max_energy_battery_kwh: 80.into(),
1634            max_current_supply_amp: 2.into(),
1635        };
1636        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1637        let warnings = assert_matches!(err, generate::Error::Tariff(w) => w);
1638        assert_matches!(
1639            warnings.into_kind_vec().as_slice(),
1640            [generate::WarningKind::NoElements]
1641        );
1642    }
1643}
1644
1645#[cfg(test)]
1646mod test_generate_from_single_elem_tariff {
1647    use assert_matches::assert_matches;
1648    use chrono::TimeDelta;
1649
1650    use crate::{
1651        assert_approx_eq,
1652        generate::{self, PartialCdr},
1653        tariff, Kwh, Money, Price,
1654    };
1655
1656    use super::test;
1657
1658    const DATE: &str = "2025-11-10";
1659    const TARIFF_JSON: &str = r#"{
1660    "country_code": "DE",
1661    "party_id": "ALL",
1662    "id": "1",
1663    "currency": "EUR",
1664    "type": "REGULAR",
1665    "elements": [
1666        {
1667            "price_components": [{
1668                  "type": "ENERGY",
1669                  "price": 0.50,
1670                  "vat": 20.0,
1671                  "step_size": 1
1672            }]
1673        }
1674    ],
1675    "last_updated": "2018-12-05T12:01:09Z"
1676}
1677"#;
1678
1679    fn generate_config() -> generate::Config {
1680        generate::Config {
1681            timezone: chrono_tz::Europe::Amsterdam,
1682            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1683            end_date_time: test::datetime_utc(DATE, "15:12:12"),
1684            max_power_supply_kw: 12.into(),
1685            max_energy_battery_kwh: 80.into(),
1686            max_current_supply_amp: 2.into(),
1687        }
1688    }
1689
1690    #[track_caller]
1691    fn generate(tariff_json: &str) -> generate::Caveat<generate::Report> {
1692        let tariff = tariff::parse(tariff_json).unwrap().unwrap_certain();
1693        generate::cdr_from_tariff(&tariff, generate_config()).unwrap()
1694    }
1695
1696    #[test]
1697    fn should_warn_duration_below_min() {
1698        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1699        let config = generate::Config {
1700            timezone: chrono_tz::Europe::Amsterdam,
1701            start_date_time: test::datetime_utc(DATE, "15:02:12"),
1702            end_date_time: test::datetime_utc(DATE, "15:03:12"),
1703            max_power_supply_kw: 12.into(),
1704            max_energy_battery_kwh: 80.into(),
1705            max_current_supply_amp: 2.into(),
1706        };
1707        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1708        assert_matches!(err, generate::Error::DurationBelowMinimum);
1709    }
1710
1711    #[test]
1712    fn should_warn_end_before_start() {
1713        let tariff = tariff::parse(TARIFF_JSON).unwrap().unwrap_certain();
1714        let config = generate::Config {
1715            timezone: chrono_tz::Europe::Amsterdam,
1716            start_date_time: test::datetime_utc(DATE, "15:12:12"),
1717            end_date_time: test::datetime_utc(DATE, "15:02:12"),
1718            max_power_supply_kw: 12.into(),
1719            max_energy_battery_kwh: 80.into(),
1720            max_current_supply_amp: 2.into(),
1721        };
1722        let err = generate::cdr_from_tariff(&tariff, config).unwrap_err();
1723        assert_matches!(err, generate::Error::StartDateTimeIsAfterEndDateTime);
1724    }
1725
1726    #[test]
1727    fn should_generate_energy_for_ten_minutes() {
1728        let report = generate(TARIFF_JSON);
1729        let (report, warnings) = report.into_parts();
1730        assert_matches!(warnings.into_kind_vec().as_slice(), []);
1731
1732        let PartialCdr {
1733            country_code: _,
1734            party_id: _,
1735            start_date_time: _,
1736            end_date_time: _,
1737            currency: _,
1738            total_energy,
1739            total_time,
1740            total_parking_time,
1741            total_cost,
1742            total_energy_cost,
1743            total_fixed_cost,
1744            total_parking_cost,
1745            total_time_cost,
1746            charging_periods: _,
1747        } = report.partial_cdr;
1748
1749        assert_approx_eq!(
1750            total_cost,
1751            Some(Price {
1752                excl_vat: Money::from(1),
1753                incl_vat: Some(Money::from(1.2))
1754            })
1755        );
1756        assert_eq!(
1757            total_time,
1758            Some(TimeDelta::minutes(10)),
1759            "The charging session is 10 min and is stopped before the battery is fully charged."
1760        );
1761        assert_eq!(
1762            total_parking_time, None,
1763            "There is no parking time since the battery never fully charged."
1764        );
1765        assert_approx_eq!(total_energy, Some(Kwh::from(2)));
1766        assert_approx_eq!(
1767            total_energy_cost,
1768            Some(Price {
1769                excl_vat: Money::from(1),
1770                incl_vat: Some(Money::from(1.2))
1771            }),
1772            "The cost per KwH is 50 cents and the VAT is 20%."
1773        );
1774        assert_eq!(total_fixed_cost, None, "There are no fixed costs.");
1775        assert_eq!(
1776            total_parking_cost, None,
1777            "There is no parking cost as there is no parking time."
1778        );
1779        assert_eq!(
1780            total_time_cost, None,
1781            "There are no time costs defined in the tariff."
1782        );
1783    }
1784}
1785
1786#[cfg(test)]
1787mod test_clamp_date_time_span {
1788    use super::{clamp_date_time_span, DateTimeSpan};
1789
1790    use super::test::{date_time_span, datetime_utc};
1791
1792    #[test]
1793    fn should_not_clamp_if_start_and_end_are_none() {
1794        let in_span = date_time_span("2025-11-01", "12:02:00", "2025-11-10", "14:00:00");
1795
1796        let out_span = clamp_date_time_span(None, None, in_span.clone());
1797
1798        assert_eq!(in_span, out_span);
1799    }
1800
1801    #[test]
1802    fn should_not_clamp_if_start_and_end_are_contained() {
1803        let start = datetime_utc("2025-11-01", "12:02:00");
1804        let end = datetime_utc("2025-11-10", "14:00:00");
1805        let in_span = DateTimeSpan { start, end };
1806        let min_date = datetime_utc("2025-11-01", "11:00:00");
1807        let max_date = datetime_utc("2025-11-10", "15:00:00");
1808
1809        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span.clone());
1810
1811        assert_eq!(in_span, out_span);
1812    }
1813
1814    #[test]
1815    fn should_clamp_if_span_start_earlier() {
1816        let start = datetime_utc("2025-11-01", "12:02:00");
1817        let end = datetime_utc("2025-11-10", "14:00:00");
1818        let in_span = DateTimeSpan { start, end };
1819        let min_date = datetime_utc("2025-11-02", "00:00:00");
1820        let max_date = datetime_utc("2025-11-10", "23:00:00");
1821
1822        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1823
1824        assert_eq!(out_span.start, min_date);
1825        assert_eq!(out_span.end, end);
1826    }
1827
1828    #[test]
1829    fn should_clamp_if_end_later() {
1830        let start = datetime_utc("2025-11-01", "12:02:00");
1831        let end = datetime_utc("2025-11-10", "14:00:00");
1832        let in_span = DateTimeSpan { start, end };
1833        let min_date = datetime_utc("2025-11-01", "00:00:00");
1834        let max_date = datetime_utc("2025-11-09", "23:00:00");
1835
1836        let out_span = clamp_date_time_span(Some(min_date), Some(max_date), in_span);
1837
1838        assert_eq!(out_span.start, start);
1839        assert_eq!(out_span.end, max_date);
1840    }
1841}
1842
1843#[cfg(test)]
1844mod test_gen_time_events {
1845    use assert_matches::assert_matches;
1846    use chrono::TimeDelta;
1847
1848    use super::{generate_time_events, v2x::TimeRestrictions};
1849
1850    use super::test::date_time_span;
1851
1852    #[test]
1853    fn should_emit_no_events_before_start_time() {
1854        // The chargesession takes place before the `start_time`
1855        let events = generate_time_events(
1856            chrono_tz::Tz::Europe__Amsterdam,
1857            date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1858            TimeRestrictions {
1859                start_time: Some("15:00".parse().unwrap()),
1860                ..TimeRestrictions::default()
1861            },
1862        );
1863
1864        assert_matches!(events.as_slice(), []);
1865    }
1866
1867    #[test]
1868    fn should_emit_no_events_finishes_at_start_time_pricisely() {
1869        // The chargesession takes place before the `start_time`
1870        let events = generate_time_events(
1871            chrono_tz::Tz::Europe__Amsterdam,
1872            date_time_span("2025-11-10", "12:02:00", "2025-11-10", "14:00:00"),
1873            TimeRestrictions {
1874                start_time: Some("15:00".parse().unwrap()),
1875                ..TimeRestrictions::default()
1876            },
1877        );
1878
1879        assert_matches!(events.as_slice(), []);
1880    }
1881
1882    #[test]
1883    fn should_emit_one_event_precise_overlap_with_start_time() {
1884        // The chargesession starts precisely when the `start_time` generates an event.
1885        let events = generate_time_events(
1886            chrono_tz::Tz::Europe__Amsterdam,
1887            date_time_span("2025-11-10", "15:00:00", "2025-11-10", "17:00:00"),
1888            TimeRestrictions {
1889                start_time: Some("15:00".parse().unwrap()),
1890                ..TimeRestrictions::default()
1891            },
1892        );
1893
1894        let [event] = events.try_into().unwrap();
1895        assert_eq!(event.duration_from_start, TimeDelta::zero());
1896    }
1897
1898    #[test]
1899    fn should_emit_one_event_hour_before_start_time() {
1900        // The chargesession starts an hour before the `start_time` generates an event.
1901        let events = generate_time_events(
1902            chrono_tz::Tz::Europe__Amsterdam,
1903            date_time_span("2025-11-10", "14:00:00", "2025-11-10", "17:00:00"),
1904            TimeRestrictions {
1905                start_time: Some("15:00".parse().unwrap()),
1906                ..TimeRestrictions::default()
1907            },
1908        );
1909
1910        let [event] = events.try_into().unwrap();
1911        assert_eq!(event.duration_from_start, TimeDelta::hours(1));
1912    }
1913
1914    #[test]
1915    fn should_emit_one_event_almost_full_day() {
1916        // The chargesession last from precisely the `start_time` of one day
1917        // until just before the `start_time` of the next.
1918        let events = generate_time_events(
1919            chrono_tz::Tz::Europe__Amsterdam,
1920            date_time_span("2025-11-10", "15:00:00", "2025-11-11", "14:59:00"),
1921            TimeRestrictions {
1922                start_time: Some("15:00".parse().unwrap()),
1923                ..TimeRestrictions::default()
1924            },
1925        );
1926
1927        let [event] = events.try_into().unwrap();
1928        assert_eq!(event.duration_from_start, TimeDelta::zero());
1929    }
1930
1931    #[test]
1932    fn should_emit_two_events_full_day_precisely() {
1933        let events = generate_time_events(
1934            chrono_tz::Tz::Europe__Amsterdam,
1935            date_time_span("2025-11-10", "15:00:00", "2025-11-11", "15:00:00"),
1936            TimeRestrictions {
1937                start_time: Some("15:00".parse().unwrap()),
1938                ..TimeRestrictions::default()
1939            },
1940        );
1941
1942        let [event_0, event_1] = events.try_into().unwrap();
1943        assert_eq!(event_0.duration_from_start, TimeDelta::zero());
1944        assert_eq!(event_1.duration_from_start, TimeDelta::days(1));
1945    }
1946
1947    #[test]
1948    fn should_emit_two_events_full_day_with_hour_margin() {
1949        let events = generate_time_events(
1950            chrono_tz::Tz::Europe__Amsterdam,
1951            date_time_span("2025-11-10", "14:00:00", "2025-11-11", "16:00:00"),
1952            TimeRestrictions {
1953                start_time: Some("15:00".parse().unwrap()),
1954                ..TimeRestrictions::default()
1955            },
1956        );
1957
1958        let [event_0, event_1] = events.try_into().unwrap();
1959        assert_eq!(event_0.duration_from_start, TimeDelta::hours(1));
1960        assert_eq!(
1961            event_1.duration_from_start,
1962            TimeDelta::days(1) + TimeDelta::hours(1)
1963        );
1964    }
1965}