Skip to main content

ocpi_tariffs/
generate.rs

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