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