Skip to main content

ocpi_tariffs/
generate.rs

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