Skip to main content

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