Skip to main content

ocpi_tariffs/
generate.rs

1#[cfg(test)]
2mod test;
3
4#[cfg(test)]
5mod test_clamp_date_time_span;
6
7#[cfg(test)]
8mod test_gen_time_events;
9
10#[cfg(test)]
11mod test_generate;
12
13#[cfg(test)]
14mod test_generate_from_single_elem_tariff;
15
16#[cfg(test)]
17mod test_local_to_utc;
18
19#[cfg(test)]
20mod test_periods;
21
22#[cfg(test)]
23mod test_power_to_time;
24
25mod v2x;
26
27use std::{
28    cmp::{max, min},
29    fmt,
30    ops::Range,
31};
32
33use chrono::{DateTime, Datelike as _, NaiveDateTime, NaiveTime, TimeDelta, Utc};
34use rust_decimal::{prelude::ToPrimitive, Decimal};
35use rust_decimal_macros::dec;
36use tracing::warn;
37
38use crate::{
39    country, currency,
40    duration::ToHoursDecimal,
41    energy::{Ampere, Kw, Kwh},
42    from_warning_all, into_caveat, into_caveat_all,
43    json::FromJson as _,
44    number::{FromDecimal as _, RoundDecimal},
45    price, tariff,
46    warning::{self, GatherWarnings as _, IntoCaveat, WithElement as _},
47    Price, Version, Versioned,
48};
49
50/// The minimum duration of a CDR. Anything below this will result in an Error.
51const MIN_CS_DURATION_SECS: i64 = 120;
52
53type DateTimeSpan = Range<DateTime<Utc>>;
54type Verdict<T> = crate::Verdict<T, Warning>;
55pub type Caveat<T> = warning::Caveat<T, Warning>;
56
57/// Return the value if `Some`. Otherwise, bail(return) with an `Error::Internal` containing the giving message.
58macro_rules! some_dec_or_bail {
59    ($elem:expr, $opt:expr, $warnings:expr, $msg:literal) => {
60        match $opt {
61            Some(v) => v,
62            None => {
63                return $warnings.bail(Warning::Decimal($msg), $elem.as_element());
64            }
65        }
66    };
67}
68
69/// The outcome of calling [`crate::cdr::generate_from_tariff`].
70#[derive(Debug)]
71pub struct Report {
72    /// The ID of the parsed tariff.
73    pub tariff_id: String,
74
75    // The currency code of the parsed tariff.
76    pub tariff_currency_code: currency::Code,
77
78    /// A partial CDR that can be fleshed out by the caller.
79    ///
80    /// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
81    /// does not know anything about the EVSE location or the token used to authenticate the chargesession.
82    ///
83    /// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
84    pub partial_cdr: PartialCdr,
85}
86
87/// A partial CDR generated by the `cdr_from_tariff` function.
88///
89/// The CDR is partial as not all required fields are set as the `cdr_from_tariff` function
90/// does not know anything about the EVSE location or the token used to authenticate the chargesession.
91///
92/// * See: [OCPI spec 2.2.1: CDR](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc>)
93/// * See: [OCPI spec 2.1.1: Tariff](https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md)
94#[derive(Debug)]
95pub struct PartialCdr {
96    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this CDR.
97    pub cpo_country_code: Option<country::Code>,
98
99    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this CDR.
100    pub cpo_currency_code: currency::Code,
101
102    /// ID of the CPO that 'owns' this CDR (following the ISO-15118 standard).
103    pub party_id: Option<String>,
104
105    /// Start timestamp of the charging session.
106    pub start_date_time: DateTime<Utc>,
107
108    /// End timestamp of the charging session.
109    pub end_date_time: DateTime<Utc>,
110
111    /// Total energy charged, in kWh.
112    pub total_energy: Option<Kwh>,
113
114    /// Total time charging.
115    ///
116    /// Some if the charging happened during the session.
117    pub total_charging_duration: Option<TimeDelta>,
118
119    /// Total time not charging.
120    ///
121    /// Some if there was idle time during the session.
122    pub total_parking_duration: Option<TimeDelta>,
123
124    /// Total cost of this transaction.
125    pub total_cost: Option<Price>,
126
127    /// Total cost related to the energy dimension.
128    pub total_energy_cost: Option<Price>,
129
130    /// Total cost of the flat dimension.
131    pub total_fixed_cost: Option<Price>,
132
133    /// Total cost related to the parking time dimension.
134    pub total_parking_duration_cost: Option<Price>,
135
136    /// Total cost related to the charging time dimension.
137    pub total_charging_duration_cost: Option<Price>,
138
139    /// List of charging periods that make up this charging session. A session should consist of 1 or
140    /// more periods, where each period has a different relevant Tariff.
141    pub charging_periods: Vec<ChargingPeriod>,
142}
143
144/// A single charging period, containing a nonempty list of charge dimensions.
145///
146/// * 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>)
147#[derive(Debug)]
148pub struct ChargingPeriod {
149    /// Start timestamp of the charging period. This period ends when a next period starts, the
150    /// last period ends when the session ends
151    pub start_date_time: DateTime<Utc>,
152
153    /// List of relevant values for this charging period.
154    pub dimensions: Vec<Dimension>,
155
156    /// Unique identifier of the Tariff that is relevant for this Charging Period.
157    /// In the OCPI spec the `tariff_id` field is optional but, we always know the tariff ID
158    /// when generating a CDR.
159    pub tariff_id: Option<String>,
160}
161
162/// The volume that has been consumed for a specific dimension during a charging period.
163///
164/// * 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>)
165#[derive(Debug)]
166pub struct Dimension {
167    pub dimension_type: DimensionType,
168
169    /// Volume of the dimension consumed, measured according to the dimension type.
170    pub volume: Decimal,
171}
172
173/// The volume that has been consumed for a specific dimension during a charging period.
174///
175/// * 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>)
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum DimensionType {
178    /// Consumed energy in `kWh`.
179    Energy,
180    /// The peak current, in 'A', during this period.
181    MaxCurrent,
182    /// The lowest current, in `A`, during this period.
183    MinCurrent,
184    /// The maximum power, in 'kW', reached during this period.
185    MaxPower,
186    /// The minimum power, in 'kW', reached during this period.
187    MinPower,
188    /// The parking time, in hours, consumed in this period.
189    ParkingTime,
190    /// The reservation time, in hours, consumed in this period.
191    ReservationTime,
192    /// The charging time, in hours, consumed in this period.
193    Time,
194}
195
196into_caveat_all!(Report, Timeline);
197
198/// Generate a CDR from a given tariff.
199pub fn cdr_from_tariff(tariff_elem: &tariff::Versioned<'_>, config: Config) -> Verdict<Report> {
200    let mut warnings = warning::Set::new();
201    // To generate a CDR from a tariff first, the tariff is parsed into structured data.
202    // Then some broad metrics are calculated that define limits on the chargesession.
203    //
204    // A Timeline of Events is then constructed by generating Events for each Element and each restriction.
205    // Some restrictions are periodic and can result in many `Event`s.
206    //
207    // The `Timeline` of `Event`s are then sorted by time and converted into a list of `ChargePeriods`.
208    let (metrics, timezone) = metrics(tariff_elem, config)?.gather_warnings_into(&mut warnings);
209
210    let tariff = match tariff_elem.version() {
211        Version::V211 => {
212            let tariff = tariff::v211::Tariff::from_json(tariff_elem.as_element())?
213                .gather_warnings_into(&mut warnings);
214
215            tariff::v221::Tariff::from(tariff)
216        }
217        Version::V221 => tariff::v221::Tariff::from_json(tariff_elem.as_element())?
218            .gather_warnings_into(&mut warnings),
219    };
220
221    if !is_tariff_active(&metrics.start_date_time, &tariff) {
222        warnings.insert(tariff::Warning::NotActive.into(), tariff_elem.as_element());
223    }
224
225    let timeline = timeline(timezone, &metrics, &tariff);
226    let charging_periods = charge_periods(&metrics, timeline);
227
228    let report = price::periods(metrics.end_date_time, timezone, &tariff, charging_periods)
229        .with_element(tariff_elem.as_element())?
230        .gather_warnings_into(&mut warnings);
231
232    let price::PeriodsReport {
233        billable: _,
234        periods,
235        totals,
236        total_costs,
237    } = report;
238
239    let charging_periods = periods
240        .into_iter()
241        .map(|period| {
242            let price::PeriodReport {
243                start_date_time,
244                end_date_time: _,
245                dimensions,
246            } = period;
247            let time = dimensions
248                .duration_charging
249                .volume
250                .as_ref()
251                .map(|dt| Dimension {
252                    dimension_type: DimensionType::Time,
253                    volume: ToHoursDecimal::to_hours_dec(dt),
254                });
255            let parking_time = dimensions
256                .duration_parking
257                .volume
258                .as_ref()
259                .map(|dt| Dimension {
260                    dimension_type: DimensionType::ParkingTime,
261                    volume: ToHoursDecimal::to_hours_dec(dt),
262                });
263            let energy = dimensions.energy.volume.as_ref().map(|kwh| Dimension {
264                dimension_type: DimensionType::Energy,
265                volume: (*kwh).into(),
266            });
267            let dimensions = vec![energy, parking_time, time]
268                .into_iter()
269                .flatten()
270                .collect();
271
272            ChargingPeriod {
273                start_date_time,
274                dimensions,
275                tariff_id: Some(tariff.id.to_string()),
276            }
277        })
278        .collect();
279
280    let mut total_cost = total_costs.total();
281
282    if let Some(total_cost) = total_cost.as_mut() {
283        if let Some(min_price) = tariff.min_price {
284            if *total_cost < min_price {
285                *total_cost = min_price;
286                warnings.insert(
287                    tariff::Warning::TotalCostClampedToMin.into(),
288                    tariff_elem.as_element(),
289                );
290            }
291        }
292
293        if let Some(max_price) = tariff.max_price {
294            if *total_cost > max_price {
295                *total_cost = max_price;
296                warnings.insert(
297                    tariff::Warning::TotalCostClampedToMin.into(),
298                    tariff_elem.as_element(),
299                );
300            }
301        }
302    }
303
304    let report = Report {
305        tariff_id: tariff.id.to_string(),
306        tariff_currency_code: tariff.currency,
307        partial_cdr: PartialCdr {
308            cpo_country_code: tariff.country_code,
309            party_id: tariff.party_id.as_ref().map(ToString::to_string),
310            start_date_time: metrics.start_date_time,
311            end_date_time: metrics.end_date_time,
312            cpo_currency_code: tariff.currency,
313            total_energy: totals.energy.round_to_ocpi_scale(),
314            total_charging_duration: totals.duration_charging,
315            total_parking_duration: totals.duration_parking,
316            total_cost: total_cost.round_to_ocpi_scale(),
317            total_energy_cost: total_costs.energy.round_to_ocpi_scale(),
318            total_fixed_cost: total_costs.fixed.round_to_ocpi_scale(),
319            total_parking_duration_cost: total_costs.duration_parking.round_to_ocpi_scale(),
320            total_charging_duration_cost: total_costs.duration_charging.round_to_ocpi_scale(),
321            charging_periods,
322        },
323    };
324
325    Ok(report.into_caveat(warnings))
326}
327
328/// Make a `Timeline` of `Event`s using the `Metric`s and `Tariff`.
329fn timeline(
330    timezone: chrono_tz::Tz,
331    metrics: &Metrics,
332    tariff: &tariff::v221::Tariff<'_>,
333) -> Timeline {
334    let mut events = vec![];
335
336    let Metrics {
337        start_date_time: cdr_start,
338        end_date_time: cdr_end,
339        duration_charging,
340        duration_parking,
341        max_power_supply,
342        max_current_supply,
343
344        energy_supplied: _,
345    } = metrics;
346
347    events.push(Event {
348        duration_from_start: TimeDelta::seconds(0),
349        kind: EventKind::SessionStart,
350    });
351
352    events.push(Event {
353        duration_from_start: *duration_charging,
354        kind: EventKind::ChargingEnd,
355    });
356
357    if let Some(duration_parking) = duration_parking {
358        events.push(Event {
359            duration_from_start: *duration_parking,
360            kind: EventKind::ParkingEnd {
361                start: metrics.duration_charging,
362            },
363        });
364    }
365
366    // True if `min_current` or `max_current` restrictions are defined.
367    // Then we set current to be consumed for each period.
368    let mut emit_current = false;
369
370    // True if `min_power` or `max_power` restrictions are defined.
371    // Then we set power to be consumed for each period.
372    let mut emit_power = false;
373
374    for elem in &tariff.elements {
375        if let Some((time_restrictions, energy_restrictions)) = elem
376            .restrictions
377            .as_ref()
378            .map(tariff::v221::Restrictions::restrictions_by_category)
379        {
380            let mut time_events =
381                generate_time_events(timezone, *cdr_start..*cdr_end, time_restrictions);
382
383            let v2x::EnergyRestrictions {
384                min_kwh,
385                max_kwh,
386                min_current,
387                max_current,
388                min_power,
389                max_power,
390            } = energy_restrictions;
391
392            if !emit_current {
393                // If the generator current is contained within the restriction, then we set
394                // an amount of current to be consumed for each period.
395                //
396                // Note: The generator supplies maximum current.
397                emit_current = (min_current..=max_current).contains(&Some(*max_current_supply));
398            }
399
400            if !emit_power {
401                // If the generator power is contained within the restriction, then we set
402                // an amount of power to be consumed for each period.
403                //
404                // Note: The generator supplies maximum power.
405                emit_power = (min_power..=max_power).contains(&Some(*max_power_supply));
406            }
407
408            let mut energy_events = generate_energy_events(
409                metrics.duration_charging,
410                metrics.energy_supplied,
411                min_kwh,
412                max_kwh,
413            );
414
415            events.append(&mut time_events);
416            events.append(&mut energy_events);
417        }
418    }
419
420    Timeline {
421        events,
422        emit_current,
423        emit_power,
424    }
425}
426
427/// Generate a list of `Event`s based on the `TimeRestrictions` an `Element` has.
428fn generate_time_events(
429    timezone: chrono_tz::Tz,
430    cdr_span: DateTimeSpan,
431    restrictions: v2x::TimeRestrictions,
432) -> Vec<Event> {
433    const MIDNIGHT: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0)
434        .expect("The hour, minute and second values are correct and hardcoded");
435    const ONE_DAY: TimeDelta = TimeDelta::days(1);
436
437    let v2x::TimeRestrictions {
438        start_time,
439        end_time,
440        start_date,
441        end_date,
442        min_duration,
443        max_duration,
444        weekdays,
445    } = restrictions;
446    let mut events = vec![];
447
448    let cdr_duration = cdr_span.end.signed_duration_since(cdr_span.start);
449
450    // If `min_duration` occur within the duration of the chargesession add an event.
451    if let Some(min_duration) = min_duration.filter(|dt| &cdr_duration < dt) {
452        events.push(Event {
453            duration_from_start: min_duration,
454            kind: EventKind::MinDuration,
455        });
456    }
457
458    // If `max_duration` occur within the duration of the chargesession add an event.
459    if let Some(max_duration) = max_duration.filter(|dt| &cdr_duration < dt) {
460        events.push(Event {
461            duration_from_start: max_duration,
462            kind: EventKind::MaxDuration,
463        });
464    }
465
466    // Here we create the `NaiveDateTime` range by combining the `start_date` (`NaiveDate`) and
467    // `start_time` (`NaiveTime`) and the associated `end_date` and `end_time`.
468    //
469    // If `start_time` or `end_time` are `None` then their respective `NaiveDate` is combined
470    // with the `NaiveTime` of `00:00:00` to form a `NaiveDateTime`.
471    //
472    // If the `end_time < start_time` then the period wraps around to the following day.
473    //
474    // See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>
475    let (start_date_time, end_date_time) =
476        if let (Some(start_time), Some(end_time)) = (start_time, end_time) {
477            if end_time < start_time {
478                (
479                    start_date.map(|d| d.and_time(start_time)),
480                    end_date.map(|d| {
481                        let (end_time, _) = end_time.overflowing_add_signed(ONE_DAY);
482                        d.and_time(end_time)
483                    }),
484                )
485            } else {
486                (
487                    start_date.map(|d| d.and_time(start_time)),
488                    end_date.map(|d| d.and_time(end_time)),
489                )
490            }
491        } else {
492            (
493                start_date.map(|d| d.and_time(start_time.unwrap_or(MIDNIGHT))),
494                end_date.map(|d| d.and_time(end_time.unwrap_or(MIDNIGHT))),
495            )
496        };
497
498    // If `start_date` or `end_date` is set we clamp the cdr_span to those dates.
499    // As we are not going to produce any events before `start_date` or after `end_date`.
500    let event_span = clamp_date_time_span(
501        start_date_time.and_then(|d| local_to_utc(timezone, d)),
502        end_date_time.and_then(|d| local_to_utc(timezone, d)),
503        cdr_span,
504    );
505
506    if let Some(start_time) = start_time {
507        let mut start_events =
508            gen_naive_time_events(&event_span, start_time, &weekdays, EventKind::StartTime);
509        events.append(&mut start_events);
510    }
511
512    if let Some(end_time) = end_time {
513        let mut end_events =
514            gen_naive_time_events(&event_span, end_time, &weekdays, EventKind::EndTime);
515        events.append(&mut end_events);
516    }
517
518    events
519}
520
521/// Convert a `NaiveDateTime` to a `DateTime<Utc>` using the local timezone.
522///
523/// Return Some `DateTime<Utc>` if the conversion from `NaiveDateTime` results in either a single
524/// or ambiguous `DateTime`. If the conversion is _ambiguous_ due to a _fold_ in the local time,
525/// then we return the earliest `DateTime`.
526fn local_to_utc(timezone: chrono_tz::Tz, date_time: NaiveDateTime) -> Option<DateTime<Utc>> {
527    use chrono::offset::LocalResult;
528
529    let result = date_time.and_local_timezone(timezone);
530
531    let local_date_time = match result {
532        LocalResult::Single(d) => d,
533        LocalResult::Ambiguous(earliest, _latest) => earliest,
534        LocalResult::None => return None,
535    };
536
537    Some(local_date_time.to_utc())
538}
539
540/// Generate `Event`s for the `start_time` or `end_time` restriction.
541fn gen_naive_time_events(
542    event_span: &Range<DateTime<Utc>>,
543    time: NaiveTime,
544    weekdays: &v2x::WeekdaySet,
545    kind: EventKind,
546) -> Vec<Event> {
547    let mut events = vec![];
548    let time_delta = time.signed_duration_since(event_span.start.time());
549    let cdr_duration = event_span.end.signed_duration_since(event_span.start);
550
551    // If the start time is before the CDR start, we move it forward 24hours
552    // and test again.
553    let time_delta = if time_delta.num_seconds().is_negative() {
554        let (time_delta, _) = time.overflowing_add_signed(TimeDelta::days(1));
555        time_delta.signed_duration_since(event_span.start.time())
556    } else {
557        time_delta
558    };
559
560    // If the start delta is still negative after moving it forward 24 hours
561    if time_delta.num_seconds().is_negative() {
562        return vec![];
563    }
564
565    // The time is after the CDR start.
566    let Some(remainder) = cdr_duration.checked_sub(&time_delta) else {
567        warn!("TimeDelta overflow");
568        return events;
569    };
570
571    if remainder.num_seconds().is_positive() {
572        let duration_from_start = time_delta;
573        let Some(date) = event_span.start.checked_add_signed(duration_from_start) else {
574            warn!("Date out of range");
575            return events;
576        };
577
578        if weekdays.contains(date.weekday()) {
579            // The time is before the CDR end.
580            events.push(Event {
581                duration_from_start: time_delta,
582                kind,
583            });
584        }
585
586        for day in 1..=remainder.num_days() {
587            let Some(duration_from_start) = time_delta.checked_add(&TimeDelta::days(day)) else {
588                warn!("Date out of range");
589                break;
590            };
591            let Some(date) = event_span.start.checked_add_signed(duration_from_start) else {
592                warn!("Date out of range");
593                break;
594            };
595
596            if weekdays.contains(date.weekday()) {
597                events.push(Event {
598                    duration_from_start,
599                    kind,
600                });
601            }
602        }
603    }
604
605    events
606}
607
608/// Generate a list of `Event`s based on the `TimeRestrictions` an `Element` has.
609fn generate_energy_events(
610    duration_charging: TimeDelta,
611    energy_supplied: Kwh,
612    min_kwh: Option<Kwh>,
613    max_kwh: Option<Kwh>,
614) -> Vec<Event> {
615    let mut events = vec![];
616
617    if let Some(duration_from_start) =
618        min_kwh.and_then(|kwh| power_to_time(kwh, energy_supplied, duration_charging))
619    {
620        events.push(Event {
621            duration_from_start,
622            kind: EventKind::MinKwh,
623        });
624    }
625
626    if let Some(duration_from_start) =
627        max_kwh.and_then(|kwh| power_to_time(kwh, energy_supplied, duration_charging))
628    {
629        events.push(Event {
630            duration_from_start,
631            kind: EventKind::MaxKwh,
632        });
633    }
634
635    events
636}
637
638/// Map power usage to time presuming a linear power consumption.
639fn power_to_time(power: Kwh, power_total: Kwh, duration_total: TimeDelta) -> Option<TimeDelta> {
640    use rust_decimal::prelude::ToPrimitive;
641
642    // Find the time that the `min_kwh` amount of power was reached.
643    // It has to be within the charging time.
644    let power = Decimal::from(power);
645    // The total power supplied during the chargesession
646    let power_total = Decimal::from(power_total);
647    // The factor minimum of the total power supplied.
648
649    let Some(factor) = power.checked_div(power_total) else {
650        return Some(TimeDelta::zero());
651    };
652
653    if factor.is_sign_negative() || factor > dec!(1.0) {
654        return None;
655    }
656
657    let duration_from_start = factor.checked_mul(Decimal::from(duration_total.num_seconds()))?;
658    duration_from_start.to_i64().map(TimeDelta::seconds)
659}
660
661/// Generate a list of charging periods for the given tariffs timeline.
662fn charge_periods(metrics: &Metrics, timeline: Timeline) -> Vec<price::Period> {
663    /// Keep track of the whether we are charging or parking.
664    enum ChargingPhase {
665        Charging,
666        Parking,
667    }
668
669    let Metrics {
670        start_date_time: cdr_start,
671        max_power_supply,
672        max_current_supply,
673
674        end_date_time: _,
675        duration_charging: _,
676        duration_parking: _,
677        energy_supplied: _,
678    } = metrics;
679
680    let Timeline {
681        mut events,
682        emit_current,
683        emit_power,
684    } = timeline;
685
686    events.sort_unstable_by_key(|e| e.duration_from_start);
687
688    let mut periods = vec![];
689    let emit_current = emit_current.then_some(*max_current_supply);
690    let emit_power = emit_power.then_some(*max_power_supply);
691    // Charging starts instantly in this model.
692    let mut charging_phase = ChargingPhase::Charging;
693
694    for items in events.windows(2) {
695        let [event, event_next] = items else {
696            unreachable!("The window size is 2");
697        };
698
699        let Event {
700            duration_from_start,
701            kind,
702        } = event;
703
704        if let EventKind::ChargingEnd = kind {
705            charging_phase = ChargingPhase::Parking;
706        }
707
708        let Some(duration) = event_next
709            .duration_from_start
710            .checked_sub(duration_from_start)
711        else {
712            warn!("TimeDelta overflow");
713            break;
714        };
715        let Some(start_date_time) = cdr_start.checked_add_signed(*duration_from_start) else {
716            warn!("TimeDelta overflow");
717            break;
718        };
719
720        let consumed = if let ChargingPhase::Charging = charging_phase {
721            let Some(energy) =
722                Decimal::from(*max_power_supply).checked_mul(duration.to_hours_dec())
723            else {
724                warn!("Decimal overflow");
725                break;
726            };
727            price::Consumed {
728                duration_charging: Some(duration),
729                duration_parking: None,
730                energy: Some(Kwh::from_decimal(energy)),
731                current_max: emit_current,
732                current_min: emit_current,
733                power_max: emit_power,
734                power_min: emit_power,
735            }
736        } else {
737            price::Consumed {
738                duration_charging: None,
739                duration_parking: Some(duration),
740                energy: None,
741                current_max: None,
742                current_min: None,
743                power_max: None,
744                power_min: None,
745            }
746        };
747
748        let period = price::Period {
749            start_date_time,
750            consumed,
751        };
752
753        periods.push(period);
754    }
755
756    periods
757}
758
759/// A `DateTimeSpan` bounded by a minimum and a maximum
760///
761/// If the input `DateTimeSpan` is less than `min_date` then this returns `min_date`.
762/// If input is greater than `max_date` then this returns `max_date`.
763/// Otherwise, this returns input `DateTimeSpan`.
764fn clamp_date_time_span(
765    min_date: Option<DateTime<Utc>>,
766    max_date: Option<DateTime<Utc>>,
767    span: DateTimeSpan,
768) -> DateTimeSpan {
769    // Make sure the `min_date` is the earlier of the `min`, max pair.
770    let (min_date, max_date) = (min(min_date, max_date), max(min_date, max_date));
771
772    let start = min_date.filter(|d| &span.start < d).unwrap_or(span.start);
773    let end = max_date.filter(|d| &span.end > d).unwrap_or(span.end);
774
775    DateTimeSpan { start, end }
776}
777
778/// A timeline of events that are used to generate the `ChargePeriods` of the CDR.
779struct Timeline {
780    /// The list of `Event`s generated from the tariff.
781    events: Vec<Event>,
782
783    /// The current is within the \[`min_current`..`max_current`\] range.
784    emit_current: bool,
785
786    /// The power is within the \[`min_power`..`max_power`\] range.
787    emit_power: bool,
788}
789
790/// An event at a time along the timeline.
791#[derive(Debug)]
792struct Event {
793    /// The duration of the Event from the start of the timeline/chargesession.
794    duration_from_start: TimeDelta,
795
796    /// The kind of Event.
797    kind: EventKind,
798}
799
800/// The kind of `Event`.
801#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
802enum EventKind {
803    /// The moment a session starts.
804    ///
805    /// This is added to the list of `Event`s so that the algorithm to generate the `ChargingPeriods`
806    /// can iterate over the `Event`s using a window of size 2. The first iteration will always have
807    /// `SessionStart` as the first window element and the `Event` of interest as the second.
808    SessionStart,
809
810    /// The moment charging ends.
811    ///
812    /// Charging starts at time 0. When `ChargingEnd`s, parking starts.
813    /// This could also be the last `Event` of the chargesession.
814    ChargingEnd,
815
816    /// The moment Parking ends
817    ///
818    /// This could also be the last `Event` of the chargesession.
819    /// If a `ParkingEnd` `Event` is present in the `Timeline` then a `ChargingEnd` `Event` will precede it.
820    ParkingEnd {
821        /// The parking started when `ChargingEnd`ed.
822        start: TimeDelta,
823    },
824
825    StartTime,
826
827    EndTime,
828
829    /// Minimum duration in seconds the Charging Session MUST last (inclusive).
830    ///
831    /// When the duration of a Charging Session is longer than the defined value, this `TariffElement` is or becomes active.
832    /// Before that moment, this `TariffElement` is not yet active.
833    MinDuration,
834
835    /// Maximum duration in seconds the Charging Session MUST last (exclusive).
836    ///
837    /// When the duration of a Charging Session is shorter than the defined value, this `TariffElement` is or becomes active.
838    /// After that moment, this `TariffElement` is no longer active.
839    MaxDuration,
840
841    /// Minimum consumed energy in kWh, for example 20, valid from this amount of energy (inclusive) being used.
842    MinKwh,
843
844    /// Maximum consumed energy in kWh, for example 50, valid until this amount of energy (exclusive) being used.
845    MaxKwh,
846}
847
848/// Broad metrics calculated about the chargesession which is given as input for generating a `Timeline` of `Event`s.
849#[derive(Debug)]
850struct Metrics {
851    /// The end date the generated CDR.
852    end_date_time: DateTime<Utc>,
853
854    /// The start date the generated CDR.
855    start_date_time: DateTime<Utc>,
856
857    /// The time spent charging the battery.
858    ///
859    /// Charging begins instantly and continues without interruption until the battery is full or the
860    /// session time has elapsed.
861    duration_charging: TimeDelta,
862
863    /// The time spent parking after charging the battery.
864    ///
865    /// This duration may be `None` if the battery did not finish charging within the session time.
866    duration_parking: Option<TimeDelta>,
867
868    /// The energy that's supplied during the charging period.
869    energy_supplied: Kwh,
870
871    /// The maximum DC current that can be delivered to the battery.
872    max_current_supply: Ampere,
873
874    /// The maximum DC power(kw) that can be delivered to the battery.
875    max_power_supply: Kw,
876}
877
878into_caveat!(Metrics);
879
880/// Validate the `Config` and compute various `Metrics` based on the `Config`s fields.
881#[expect(
882    clippy::needless_pass_by_value,
883    reason = "Clippy is complaining that `Config` is not consumed by the function when it clearly is"
884)]
885fn metrics(elem: &tariff::Versioned<'_>, config: Config) -> Verdict<(Metrics, chrono_tz::Tz)> {
886    const SECS_IN_HOUR: Decimal = dec!(3600);
887
888    let warnings = warning::Set::new();
889
890    let Config {
891        start_date_time,
892        end_date_time,
893        max_power_supply_kw,
894        requested_kwh: max_energy_battery_kwh,
895        max_current_supply_amp,
896        timezone,
897    } = config;
898    let duration_session = end_date_time.signed_duration_since(start_date_time);
899
900    // Std Duration must be positive, if the end time is before the start the conversion will fail.
901    if duration_session.num_seconds().is_negative() {
902        return warnings.bail(Warning::StartDateTimeIsAfterEndDateTime, elem.as_element());
903    }
904
905    if duration_session.num_seconds() < MIN_CS_DURATION_SECS {
906        return warnings.bail(Warning::DurationBelowMinimum, elem.as_element());
907    }
908
909    // The time needed to charge the battery = battery_capacity(kWh) / power(kw)
910    let duration_full_charge_hours = some_dec_or_bail!(
911        elem,
912        max_energy_battery_kwh.checked_div(max_power_supply_kw),
913        warnings,
914        "Unable to calculate changing time"
915    );
916
917    // The charge duration taking into account that the end of the session can occur before the battery is fully charged.
918    let charge_duration_hours =
919        Decimal::min(duration_full_charge_hours, duration_session.to_hours_dec());
920
921    let power_supplied_kwh = some_dec_or_bail!(
922        elem,
923        max_energy_battery_kwh.checked_div(charge_duration_hours),
924        warnings,
925        "Unable to calculate the power supplied during the charging time"
926    );
927
928    // Convert duration from hours to seconds as we work with seconds as the unit of time.
929    let charging_duration_secs = some_dec_or_bail!(
930        elem,
931        charge_duration_hours.checked_mul(SECS_IN_HOUR),
932        warnings,
933        "Unable to convert charging time from hours to seconds"
934    );
935
936    let charging_duration_secs = some_dec_or_bail!(
937        elem,
938        charging_duration_secs.to_i64(),
939        warnings,
940        "Unable to convert charging duration Decimal to i64"
941    );
942    let duration_charging = TimeDelta::seconds(charging_duration_secs);
943
944    let duration_parking = some_dec_or_bail!(
945        elem,
946        duration_session.checked_sub(&duration_charging),
947        warnings,
948        "Unable to calculate `idle_duration`"
949    );
950
951    let metrics = Metrics {
952        end_date_time,
953        start_date_time,
954        duration_charging,
955        duration_parking: Some(duration_parking).filter(|dt| dt.num_seconds().is_positive()),
956        energy_supplied: Kwh::from_decimal(power_supplied_kwh),
957        max_current_supply: Ampere::from_decimal(max_current_supply_amp),
958        max_power_supply: Kw::from_decimal(max_power_supply_kw),
959    };
960
961    Ok((metrics, timezone).into_caveat(warnings))
962}
963
964fn is_tariff_active(cdr_start: &DateTime<Utc>, tariff: &tariff::v221::Tariff<'_>) -> bool {
965    match (tariff.start_date_time, tariff.end_date_time) {
966        (None, None) => true,
967        (None, Some(end)) => (..end).contains(cdr_start),
968        (Some(start), None) => (start..).contains(cdr_start),
969        (Some(start), Some(end)) => (start..end).contains(cdr_start),
970    }
971}
972
973#[derive(Debug)]
974pub enum Warning {
975    /// A Decimal operation failed.
976    Decimal(&'static str),
977
978    /// The duration of the chargesession is below the minimum allowed.
979    DurationBelowMinimum,
980
981    Price(price::Warning),
982
983    /// The `start_date_time` is after the `end_date_time`.
984    StartDateTimeIsAfterEndDateTime,
985
986    Tariff(tariff::Warning),
987}
988
989impl crate::Warning for Warning {
990    fn id(&self) -> warning::Id {
991        match self {
992            Self::Decimal(_) => warning::Id::from_static("decimal_error"),
993            Self::DurationBelowMinimum => warning::Id::from_static("duration_below_minimum"),
994            Self::Price(kind) => kind.id(),
995            Self::StartDateTimeIsAfterEndDateTime => {
996                warning::Id::from_static("start_time_after_end_time")
997            }
998            Self::Tariff(kind) => kind.id(),
999        }
1000    }
1001}
1002
1003impl fmt::Display for Warning {
1004    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1005        match self {
1006            Self::Decimal(msg) => f.write_str(msg),
1007            Self::DurationBelowMinimum => write!(
1008                f,
1009                "The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
1010            ),
1011            Self::Price(warnings) => {
1012                write!(f, "Price warnings: {warnings:?}")
1013            }
1014            Self::StartDateTimeIsAfterEndDateTime => {
1015                write!(f, "The `start_date_time` is after the `end_date_time`")
1016            }
1017            Self::Tariff(warnings) => {
1018                write!(f, "Tariff warnings: {warnings:?}")
1019            }
1020        }
1021    }
1022}
1023
1024from_warning_all!(
1025    tariff::Warning => Warning::Tariff,
1026    price::Warning => Warning::Price
1027);
1028
1029/// The config for generating a CDR from a tariff.
1030#[derive(Clone)]
1031pub struct Config {
1032    /// The timezone of the EVSE: The timezone where the chargesession took place.
1033    pub timezone: chrono_tz::Tz,
1034
1035    /// The start date of the generated CDR.
1036    pub end_date_time: DateTime<Utc>,
1037
1038    /// The maximum DC current that can be delivered to the battery.
1039    pub max_current_supply_amp: Decimal,
1040
1041    /// The amount of energy(kWh) the requested to be delivered.
1042    ///
1043    /// We don't model charging curves for the battery, so we don't care about the existing change of
1044    /// the battery.
1045    pub requested_kwh: Decimal,
1046
1047    /// The maximum DC power(kw) that can be delivered to the battery.
1048    ///
1049    /// This is modeled as a DC system as we don't care if the delivery medium is DC or one of the
1050    /// various AC forms. We only care what the effective DC power is. The caller of `cdr_from_tariff`
1051    /// should convert the delivery medium into a DC kw power by using a power factor.
1052    ///
1053    /// In practice the maximum power bottleneck is either the EVSE, the cable or the battery itself.
1054    /// But whatever the bottleneck is, the caller should work that out and set the maximum expected.
1055    pub max_power_supply_kw: Decimal,
1056
1057    /// The start date of the generated CDR.
1058    pub start_date_time: DateTime<Utc>,
1059}