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