Skip to main content

ocpi_tariffs/
price.rs

1//! Price a CDR using a tariff and compare the prices embedded in the CDR with the prices computed here.
2
3/// Test support shared by this module's other test files.
4#[cfg(test)]
5pub mod test;
6
7#[cfg(test)]
8mod test_normalize_periods;
9
10#[cfg(test)]
11mod test_periods;
12
13#[cfg(test)]
14mod test_real_world;
15
16#[cfg(test)]
17mod test_validate_cdr;
18
19#[cfg(test)]
20mod test_current_and_power_restrictions;
21
22#[cfg(test)]
23mod test_reservation_restriction;
24
25#[cfg(test)]
26mod test_min_max_price;
27
28#[cfg(test)]
29mod test_warning_path_map;
30
31mod tariff;
32pub(crate) mod v211;
33pub(crate) mod v221;
34
35use std::{collections::BTreeMap, fmt, ops::Range};
36
37use chrono::{DateTime, Datelike as _, TimeDelta, Utc};
38use chrono_tz::Tz;
39use rust_decimal::Decimal;
40use tariff::Tariff;
41use tracing::{debug, instrument, trace};
42
43use crate::{
44    country, currency, datetime,
45    duration::{self, AsHms as _, Hms},
46    from_warning_all, json,
47    money::{self, VatOrigin},
48    number::{self, RoundDecimal as _},
49    string,
50    warning::{
51        self, GatherDeferredWarnings as _, GatherWarnings as _, IntoCaveat as _,
52        IntoCaveatDeferred as _, WithElement as _,
53    },
54    Ampere, Caveat, Cost, DisplayOption, Kw, Kwh, Money, Price, SaturatingAdd as _,
55    SaturatingSub as _, Versioned as _,
56};
57
58/// A [`Verdict`](crate::Verdict) whose warnings are this module's [`Warning`].
59pub type Verdict<T> = crate::Verdict<T, Warning>;
60type VerdictDeferred<T> = warning::VerdictDeferred<T, Warning>;
61
62/// A normalized/expanded form of a charging period to make the pricing calculation simpler.
63///
64/// The simplicity comes through avoiding having to look up the next period to figure out the end
65/// of the current period.
66#[derive(Debug)]
67struct PeriodNormalized {
68    /// The set of quantities consumed across the duration of the `Period`.
69    consumed: Consumed,
70
71    /// A snapshot of the values of various quantities at the start of the charge period.
72    start_snapshot: TotalsSnapshot,
73
74    /// A snapshot of the values of various quantities at the end of the charge period.
75    end_snapshot: TotalsSnapshot,
76}
77
78/// The set of quantities consumed across the duration of the `Period`.
79#[derive(Clone)]
80#[cfg_attr(test, derive(Default))]
81pub(crate) struct Consumed {
82    /// The peak current during this period.
83    pub current_max: Option<Ampere>,
84
85    /// The lowest current during this period.
86    pub current_min: Option<Ampere>,
87
88    /// The charging time consumed in this period.
89    pub duration_charging: Option<TimeDelta>,
90
91    /// The parking/idle time consumed in this period.
92    pub duration_idle: Option<TimeDelta>,
93
94    /// The energy consumed in this period.
95    pub energy: Option<Kwh>,
96
97    /// The maximum power reached during this period.
98    pub power_max: Option<Kw>,
99
100    /// The minimum power reached during this period.
101    pub power_min: Option<Kw>,
102}
103
104impl fmt::Debug for Consumed {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("Consumed")
107            .field("current_max", &self.current_max)
108            .field("current_min", &self.current_min)
109            .field(
110                "duration_charging",
111                &self.duration_charging.map(|dt| dt.as_hms()),
112            )
113            .field("duration_idle", &self.duration_idle.map(|dt| dt.as_hms()))
114            .field("energy", &self.energy)
115            .field("power_max", &self.power_max)
116            .field("power_min", &self.power_min)
117            .finish()
118    }
119}
120
121/// A snapshot of the values of various quantities at the start and end of the charge period.
122#[derive(Clone)]
123struct TotalsSnapshot {
124    /// The `DateTime` this snapshot of total quantities was taken.
125    date_time: DateTime<Utc>,
126
127    /// The total energy consumed during a charging period.
128    energy: Kwh,
129
130    /// The local timezone.
131    local_timezone: Tz,
132
133    /// The total charging duration during a charging period.
134    duration_charging: TimeDelta,
135
136    /// The total period duration during a charging period.
137    duration_total: TimeDelta,
138}
139
140impl fmt::Debug for TotalsSnapshot {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.debug_struct("TotalsSnapshot")
143            .field("date_time", &self.date_time)
144            .field("energy", &self.energy)
145            .field("local_timezone", &self.local_timezone)
146            .field("duration_charging", &self.duration_charging.as_hms())
147            .field("duration_total", &self.duration_total.as_hms())
148            .finish()
149    }
150}
151
152impl TotalsSnapshot {
153    /// Create a snapshot where all quantities are zero.
154    fn zero(date_time: DateTime<Utc>, local_timezone: Tz) -> Self {
155        Self {
156            date_time,
157            energy: Kwh::zero(),
158            local_timezone,
159            duration_charging: TimeDelta::zero(),
160            duration_total: TimeDelta::zero(),
161        }
162    }
163
164    /// Create a new snapshot based on the current snapshot with consumed quantities added.
165    fn next(&self, consumed: &Consumed, date_time: DateTime<Utc>) -> Self {
166        let duration = date_time.signed_duration_since(self.date_time);
167
168        let mut next = Self {
169            date_time,
170            energy: self.energy,
171            local_timezone: self.local_timezone,
172            duration_charging: self.duration_charging,
173            duration_total: self.duration_total.saturating_add(duration),
174        };
175
176        if let Some(duration) = consumed.duration_charging {
177            next.duration_charging = next.duration_charging.saturating_add(duration);
178        }
179
180        if let Some(energy) = consumed.energy {
181            next.energy = next.energy.saturating_add(energy);
182        }
183        next
184    }
185
186    /// Return the local time of this snapshot.
187    fn local_time(&self) -> chrono::NaiveTime {
188        self.date_time.with_timezone(&self.local_timezone).time()
189    }
190
191    /// Return the local date of this snapshot.
192    fn local_date(&self) -> chrono::NaiveDate {
193        self.date_time
194            .with_timezone(&self.local_timezone)
195            .date_naive()
196    }
197
198    /// Return the local `Weekday` of this snapshot.
199    fn local_weekday(&self) -> chrono::Weekday {
200        self.date_time.with_timezone(&self.local_timezone).weekday()
201    }
202}
203
204/// Structure containing the charge session priced according to the specified tariff.
205/// The fields prefixed `total` correspond to CDR fields with the same name.
206pub struct Report {
207    /// Charge session details per period.
208    pub periods: Vec<PeriodReport>,
209
210    /// The index of the tariff that was used for pricing.
211    pub tariff_used: TariffOrigin,
212
213    /// A list of reports for each tariff found in the CDR or supplied to the [`cdr::price`](crate::cdr::price) function.
214    ///
215    /// The order of the `tariff::Report`s are the same as the order in which they are given.
216    pub tariff_reports: Vec<TariffReport>,
217
218    /// Time-zone that was either specified or detected.
219    pub timezone: String,
220
221    /* Billed Quantities */
222    /// The total charging time after applying step-size.
223    pub billed_charging_time: Option<TimeDelta>,
224
225    /// The total energy after applying step-size.
226    pub billed_energy: Option<Kwh>,
227
228    /// The total idle time after applying step-size.
229    pub billed_idle_time: Option<TimeDelta>,
230
231    /* Totals */
232    /// Total duration of the charging session (excluding not charging), in hours.
233    ///
234    /// This is a total that has no direct source field in the `CDR` as it is calculated in the
235    /// [`cdr::price`](crate::cdr::price) function.
236    pub total_charging_time: Option<TimeDelta>,
237
238    /// Total energy charged, in kWh.
239    pub total_energy: Total<Kwh, Option<Kwh>>,
240
241    /// Total duration of the charging session where the EV was not charging (no energy was transferred between EVSE and EV).
242    ///
243    /// See: `total_parking_time` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
244    /// Note: We use `total_idle_time` as it's clearer than `total_parking_time`.
245    /// Some people interpret `parking` to mean the total time that the vehicle is standing by the charge point.
246    /// The OCPI spec defines `parking` as the time spent not charging.
247    pub total_idle_time: Total<Option<TimeDelta>>,
248
249    /// Total duration of the charging session (including the duration of charging and idle phases).
250    pub total_time: Total<TimeDelta>,
251
252    /* Costs */
253    /// Total sum of all the costs of this transaction in the specified currency.
254    pub total_cost: Total<Price, Option<Price>>,
255
256    /// Total sum of all the cost of all the energy used, in the specified currency.
257    pub total_energy_cost: Total<Option<Price>>,
258
259    /// Total sum of all the fixed costs in the specified currency, except fixed price components of `parking` and `reservation`.
260    /// The cost not depending on amount of time/energy used etc. Can contain costs like a start tariff.
261    pub total_fixed_cost: Total<Option<Price>>,
262
263    /// Total sum of all the costs related to idleness during this transaction. This includes fixed price components, in the specified currency.
264    ///
265    /// See: `total_parking_cost` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
266    /// Note: We use `total_idle_cost` as it's clearer than `total_parking_cost`.
267    /// Some people interpret `parking` to mean the total time that the vehicle is standing by the charge point.
268    /// The OCPI spec defines `parking` as the time spent not charging.
269    pub total_idle_cost: Total<Option<Price>>,
270
271    /// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
272    ///
273    /// See: `total_time_cost` field in <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_cdrs.asciidoc#131-cdr-object>.
274    /// Note: We use `total_charging_time_cost` as it's clearer than `total_time_cost`.
275    pub total_charging_time_cost: Total<Option<Price>>,
276}
277
278impl fmt::Debug for Report {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        f.debug_struct("Report")
281            .field("periods", &self.periods)
282            .field("tariff_used", &self.tariff_used)
283            .field("tariff_reports", &self.tariff_reports)
284            .field("timezone", &self.timezone)
285            .field(
286                "billed_charging_time",
287                &self.billed_charging_time.map(|dt| dt.as_hms()),
288            )
289            .field("billed_energy", &self.billed_energy)
290            .field(
291                "billed_idle_time",
292                &self.billed_idle_time.map(|dt| dt.as_hms()),
293            )
294            .field(
295                "total_charging_time",
296                &self.total_charging_time.map(|dt| dt.as_hms()),
297            )
298            .field("total_energy", &self.total_energy)
299            .field("total_idle_time", &self.total_idle_time)
300            .field("total_time", &self.total_time)
301            .field("total_cost", &self.total_cost)
302            .field("total_energy_cost", &self.total_energy_cost)
303            .field("total_fixed_cost", &self.total_fixed_cost)
304            .field("total_idle_cost", &self.total_idle_cost)
305            .field("total_charging_time_cost", &self.total_charging_time_cost)
306            .finish()
307    }
308}
309
310/// The warnings that happen when pricing a CDR.
311#[derive(Debug)]
312pub enum Warning {
313    /// Raised while reading a country code.
314    Country(country::Warning),
315    /// Raised while reading a currency code.
316    Currency(currency::Warning),
317    /// Raised while reading a timestamp.
318    DateTime(datetime::Warning),
319    /// Raised while decoding a JSON string's escape sequences.
320    Decode(json::decode::Warning),
321    /// Raised while reading a duration.
322    Duration(duration::Warning),
323
324    /// The `$.country` field should be an alpha-2 country code.
325    ///
326    /// The alpha-3 code can be converted into an alpha-3 but the caller should be warned.
327    CountryShouldBeAlpha2,
328
329    /// Raised while reading a price.
330    Money(money::Warning),
331
332    /// The CDR has no charging periods.
333    NoPeriods,
334
335    /// No valid tariff has been found in the list of provided tariffs.
336    /// The tariff list can be sourced from either the tariffs contained in the CDR or from a list
337    /// provided by the caller.
338    ///
339    /// A valid tariff must have a start date-time before the start of the session and an end
340    /// date-time after the start of the session.
341    ///
342    /// If the CDR does not contain any tariffs consider providing a them using [`TariffSource`]
343    /// when calling [`cdr::price`](crate::cdr::price).
344    NoValidTariff,
345
346    /// Raised while reading a number.
347    Number(number::Warning),
348
349    /// The `start_date_time` of at least one of the `charging_periods` is outside of the
350    /// CDR's `start_date_time`-`end_date_time` range.
351    PeriodsOutsideStartEndDateTime {
352        /// The session's own start and end, taken from the CDR.
353        cdr_range: Range<DateTime<Utc>>,
354        /// The range the charging periods actually span.
355        period_range: PeriodRange,
356    },
357
358    /// Raised while reading a string.
359    String(string::Warning),
360
361    /// Converting the `tariff::Versioned` into a structured `tariff::v221::Tariff` caused an
362    /// unrecoverable error.
363    Tariff(crate::tariff::Warning),
364
365    /// A feature rejected the schema IR for a CDR object because a required field was missing
366    /// or invalid. The located cause is reported by the schema validation warnings.
367    /// (see [`warning::Rejected`]).
368    Rejected,
369}
370
371impl fmt::Display for Warning {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        match self {
374            Self::Country(warning) => write!(f, "{warning}"),
375            Self::CountryShouldBeAlpha2 => {
376                f.write_str("The `$.country` field should be an alpha-2 country code.")
377            }
378            Self::Currency(warning) => write!(f, "{warning}"),
379            Self::DateTime(warning) => write!(f, "{warning}"),
380            Self::Decode(warning) => write!(f, "{warning}"),
381            Self::Duration(warning) => write!(f, "{warning}"),
382            Self::Money(warning) => write!(f, "{warning}"),
383            Self::NoPeriods => f.write_str("The CDR has no charging periods"),
384            Self::NoValidTariff => {
385                f.write_str("No valid tariff has been found in the list of provided tariffs")
386            }
387            Self::Number(warning) => write!(f, "{warning}"),
388            Self::PeriodsOutsideStartEndDateTime {
389                cdr_range: Range { start, end },
390                period_range,
391            } => {
392                write!(
393                    f,
394                    "The CDR's charging period time range is not contained within the `start_date_time` \
395                    and `end_date_time`; cdr: [start: {start}, end: {end}], period: {period_range}",
396                )
397            }
398            Self::String(warning) => write!(f, "{warning}"),
399            Self::Tariff(warnings) => {
400                write!(f, "Tariff warnings: {warnings:?}")
401            }
402            Self::Rejected => f.write_str(
403                "The schema IR for a CDR object was rejected; see the schema validation warnings.",
404            ),
405        }
406    }
407}
408
409impl crate::Warning for Warning {
410    fn id(&self) -> warning::Id {
411        match self {
412            Self::Country(warning) => warning.id(),
413            Self::CountryShouldBeAlpha2 => warning::Id::from_static("country_should_be_alpha_2"),
414            Self::Currency(warning) => warning.id(),
415            Self::DateTime(warning) => warning.id(),
416            Self::Decode(warning) => warning.id(),
417            Self::Duration(warning) => warning.id(),
418            Self::Money(warning) => warning.id(),
419            Self::NoPeriods => warning::Id::from_static("no_periods"),
420            Self::NoValidTariff => warning::Id::from_static("no_valid_tariff"),
421            Self::Number(warning) => warning.id(),
422            Self::PeriodsOutsideStartEndDateTime { .. } => {
423                warning::Id::from_static("periods_outside_start_end_date_time")
424            }
425            Self::String(warning) => warning.id(),
426            Self::Tariff(warning) => warning.id(),
427            Self::Rejected => warning::Id::from_static("rejected"),
428        }
429    }
430
431    fn is_rejected(&self) -> bool {
432        matches!(self, Self::Rejected)
433    }
434}
435
436impl From<warning::Rejected> for Warning {
437    fn from(_: warning::Rejected) -> Self {
438        Self::Rejected
439    }
440}
441
442from_warning_all!(
443    country::Warning => Warning::Country,
444    currency::Warning => Warning::Currency,
445    datetime::Warning => Warning::DateTime,
446    duration::Warning => Warning::Duration,
447    json::decode::Warning => Warning::Decode,
448    money::Warning => Warning::Money,
449    number::Warning => Warning::Number,
450    string::Warning => Warning::String,
451    crate::tariff::Warning => Warning::Tariff
452);
453
454/// A report of parsing and using the referenced tariff to price a CDR.
455#[derive(Debug)]
456pub struct TariffReport {
457    /// The id of the tariff.
458    pub origin: TariffOrigin,
459
460    /// Warnings from parsing a tariff.
461    ///
462    /// Each entry in the map is an element path and a list of associated warnings.
463    pub warnings: BTreeMap<json::Path, Vec<crate::tariff::Warning>>,
464}
465
466/// The origin data for a tariff.
467#[derive(Clone, Debug)]
468pub struct TariffOrigin {
469    /// The index of the tariff in the CDR JSON or in the list of override tariffs.
470    pub index: usize,
471
472    /// The value of the `id` field in the tariff JSON.
473    pub id: String,
474
475    /// The currency code of the tariff.
476    pub currency: currency::Code,
477}
478
479/// A CDR charge period in a normalized form ready for pricing.
480#[derive(Debug)]
481pub(crate) struct Period {
482    /// The start time of this period.
483    pub start_date_time: DateTime<Utc>,
484
485    /// The quantities consumed during this period.
486    pub consumed: Consumed,
487}
488
489/// A structure containing a report for each dimension of a CDRs charging period, held by
490/// [`PeriodReport`].
491#[derive(Debug)]
492pub struct Dimensions {
493    /// Energy consumed. `None` if the CDR period had no energy dimension.
494    pub energy: Option<Dimension<Kwh>>,
495
496    /// Flat fee without unit for `step_size`.
497    pub flat: Dimension<()>,
498
499    /// Duration of time charging. `None` if the CDR period had no time dimension.
500    pub duration_charging: Option<Dimension<TimeDelta>>,
501
502    /// Duration of time not charging. `None` if the CDR period had no parking-time dimension.
503    pub duration_idle: Option<Dimension<TimeDelta>>,
504}
505
506impl Dimensions {
507    /// Create a new `Dimensions` object.
508    fn new(components: ComponentSet, consumed: &Consumed) -> Self {
509        let ComponentSet {
510            energy: energy_price,
511            flat: flat_price,
512            duration_charging: duration_charging_price,
513            duration_idle: duration_idle_price,
514        } = components;
515
516        let Consumed {
517            duration_charging,
518            duration_idle,
519            energy,
520            current_max: _,
521            current_min: _,
522            power_max: _,
523            power_min: _,
524        } = consumed;
525
526        Self {
527            energy: (*energy).map(|e| Dimension {
528                price: energy_price,
529                volume: e,
530                billed_volume: e,
531            }),
532            flat: Dimension {
533                price: flat_price,
534                volume: (),
535                billed_volume: (),
536            },
537            duration_charging: (*duration_charging).map(|dc| Dimension {
538                price: duration_charging_price,
539                volume: dc,
540                billed_volume: dc,
541            }),
542            duration_idle: (*duration_idle).map(|di| Dimension {
543                price: duration_idle_price,
544                volume: di,
545                billed_volume: di,
546            }),
547        }
548    }
549}
550
551#[derive(Debug)]
552/// A report for a single dimension during a single charging period.
553pub struct Dimension<V> {
554    /// The price component that was active during this period for this dimension.
555    /// It could be that no price component was active during this period for this dimension in
556    /// which case `price` is `None`.
557    pub price: Option<Component>,
558
559    /// The volume of this dimension during this period, as received in the provided charge detail record.
560    pub volume: V,
561
562    /// The value of `volume` after a potential step size was applied.
563    /// Step size is applied over the total volume during the whole session of a dimension. But the
564    /// resulting additional volume should be billed according to the price component in this
565    /// period.
566    ///
567    /// If no step-size was applied for this period, the volume is exactly equal to the `volume`
568    /// field.
569    pub billed_volume: V,
570}
571
572impl<V: Cost> Dimension<V> {
573    /// The total cost of this dimension during a period.
574    pub fn cost(&self) -> Option<Price> {
575        let Some(price_component) = &self.price else {
576            return None;
577        };
578
579        let excl_vat = self.billed_volume.cost(price_component.price);
580
581        let incl_vat = match price_component.vat {
582            VatOrigin::Provided(vat) => Some(excl_vat.apply_vat(vat)),
583            VatOrigin::NotProvided => Some(excl_vat),
584            VatOrigin::Unknown => None,
585        };
586
587        Some(Price { excl_vat, incl_vat })
588    }
589}
590
591/// A set of price `Component`s, one for each dimension.
592///
593/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>.
594/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#145-tariffdimensiontype-enum>.
595#[derive(Debug)]
596pub struct ComponentSet {
597    /// Energy consumed.
598    pub energy: Option<Component>,
599
600    /// Flat fee without unit for `step_size`.
601    pub flat: Option<Component>,
602
603    /// Duration of time charging.
604    pub duration_charging: Option<Component>,
605
606    /// Duration of time not charging.
607    pub duration_idle: Option<Component>,
608}
609
610impl ComponentSet {
611    /// Returns true if all components are `Some`.
612    fn has_all_components(&self) -> bool {
613        let Self {
614            energy,
615            flat,
616            duration_charging,
617            duration_idle,
618        } = self;
619
620        flat.is_some() && energy.is_some() && duration_idle.is_some() && duration_charging.is_some()
621    }
622}
623
624/// A Price Component describes how a certain amount of a certain dimension being consumed
625/// translates into an amount of money owed.
626///
627/// See: <https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class>.
628#[derive(Clone, Debug)]
629pub struct Component {
630    /// Price per unit (excl. VAT) for this dimension.
631    price: Money,
632
633    /// Applicable VAT percentage for this tariff dimension. If omitted, no VAT is applicable.
634    /// Not providing a VAT is different from 0% VAT, which would be a value of 0.0 here.
635    vat: VatOrigin,
636
637    /// Minimum amount to be billed. That is, the dimension will be billed in this `step_size` blocks.
638    /// Consumed amounts are rounded up to the smallest multiple of `step_size` that is greater than
639    /// the consumed amount.
640    ///
641    /// For example: if type is TIME and `step_size` has a value of 300, then time will be billed in
642    /// blocks of 5 minutes. If 6 minutes were consumed, 10 minutes (2 blocks of `step_size`) will
643    /// be billed.
644    step_size: u64,
645}
646
647impl Component {
648    /// Create a new `Component` object.
649    fn new(component: &crate::tariff::v221::PriceComponent) -> Self {
650        let crate::tariff::v221::PriceComponent {
651            price,
652            vat,
653            step_size,
654            dimension_type: _,
655        } = component;
656
657        Self {
658            price: *price,
659            vat: *vat,
660            step_size: *step_size,
661        }
662    }
663
664    /// Return the price of the `Component`.
665    pub fn price(&self) -> Money {
666        self.price
667    }
668}
669
670/// A related source and calculated pair of total amounts.
671///
672/// This is used to express the source and calculated amounts for the total fields of a `CDR`.
673///
674/// - `total_cost`
675/// - `total_fixed_cost`
676/// - `total_energy`
677/// - `total_energy_cost`
678/// - `total_time`
679/// - `total_time_cost`
680/// - `total_parking_time`
681/// - `total_parking_cost`
682#[derive(Debug)]
683pub struct Total<TCdr, TCalc = TCdr> {
684    /// The source value from the `CDR`.
685    pub cdr: TCdr,
686
687    /// The value calculated by the [`cdr::price`](crate::cdr::price) function.
688    pub calculated: TCalc,
689}
690
691/// The range of time the CDR periods span.
692#[derive(Debug)]
693pub enum PeriodRange {
694    /// There are many periods in the CDR and so the range is from the `start_date_time` of the first to
695    /// the `start_date_time` of the last.
696    Many(Range<DateTime<Utc>>),
697
698    /// There is one period in the CDR and so one `start_date_time`.
699    Single(DateTime<Utc>),
700}
701
702impl fmt::Display for PeriodRange {
703    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704        match self {
705            PeriodRange::Many(Range { start, end }) => write!(f, "[start: {start}, end: {end}]"),
706            PeriodRange::Single(date_time) => write!(f, "{date_time}"),
707        }
708    }
709}
710
711/// Where should the tariffs come from when pricing a `CDR`.
712///
713/// Used with [`cdr::price`](crate::cdr::price).
714#[derive(Debug)]
715pub enum TariffSource<'buf> {
716    /// Use the tariffs from the `CDR`.
717    UseCdr,
718
719    /// Ignore the tariffs from the `CDR` and use these instead.
720    Override(Vec<crate::tariff::Versioned<'buf>>),
721}
722
723impl<'buf> TariffSource<'buf> {
724    /// Convenience method to provide a single override tariff.
725    pub fn single(tariff: crate::tariff::Versioned<'buf>) -> Self {
726        Self::Override(vec![tariff])
727    }
728}
729
730/// Price a CDR.
731///
732/// See: [`crate::cdr::price`].
733#[instrument(skip_all)]
734pub(super) fn cdr(
735    cdr_elem: &crate::cdr::Versioned<'_>,
736    tariff_source: TariffSource<'_>,
737    timezone: Tz,
738) -> Verdict<Report> {
739    let cdr = cdr_elem.to_v221()?;
740
741    match tariff_source {
742        TariffSource::UseCdr => {
743            debug!("Using tariffs from CDR");
744            let tariffs = cdr_elem.tariffs_to_v221()?.ignore_warnings();
745
746            Ok(price_v221_cdr_with_tariffs(
747                cdr_elem, cdr, tariffs, timezone,
748            )?)
749        }
750        TariffSource::Override(tariffs) => {
751            debug!("Using override tariffs");
752            let tariffs = tariffs
753                .iter()
754                .map(crate::tariff::Versioned::to_v221)
755                .collect::<Result<Vec<_>, _>>()?;
756
757            Ok(price_v221_cdr_with_tariffs(
758                cdr_elem, cdr, tariffs, timezone,
759            )?)
760        }
761    }
762}
763
764/// Price a single charge-session using a tariff selected from a list.
765///
766/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
767/// Price a single charge-session using a single tariff.
768///
769/// Returns a report containing the totals, subtotals, and a breakdown of the calculation.
770fn price_v221_cdr_with_tariffs(
771    cdr_elem: &crate::cdr::Versioned<'_>,
772    cdr: Caveat<v221::Cdr, Warning>,
773    tariffs: Vec<Caveat<crate::tariff::v221::Tariff<'_>, crate::tariff::Warning>>,
774    timezone: Tz,
775) -> Verdict<Report> {
776    debug!(?timezone, version = ?cdr_elem.version(), "Pricing CDR");
777    let (cdr, mut warnings) = cdr.into_parts();
778    let v221::Cdr {
779        start_date_time,
780        end_date_time,
781        charging_periods,
782        totals: cdr_totals,
783    } = cdr;
784
785    // Convert each versioned tariff JSON to a structured tariff.
786    //
787    // This generates a list of `TariffReport`s that are returned to the caller in the `Report`.
788    // One of the structured tariffs is selected for use in the `price_periods` function.
789    let (tariff_reports, tariffs): (Vec<_>, Vec<_>) = tariffs
790        .into_iter()
791        .enumerate()
792        .map(|(index, tariff)| {
793            let (tariff, warnings) = tariff.into_parts();
794            (
795                TariffReport {
796                    origin: TariffOrigin {
797                        index,
798                        id: tariff.id.to_string(),
799                        currency: tariff.currency,
800                    },
801                    warnings: warnings.into_path_map(),
802                },
803                tariff,
804            )
805        })
806        .unzip();
807
808    debug!(tariffs = ?tariffs.iter().map(|t| t.id).collect::<Vec<_>>(), "Found tariffs(by id) in CDR");
809
810    let tariffs_normalized = tariff::normalize_all(&tariffs);
811    let Some((tariff_index, tariff)) =
812        tariff::find_first_active(tariffs_normalized, start_date_time)
813    else {
814        return warnings.bail(cdr_elem.as_element(), Warning::NoValidTariff);
815    };
816
817    debug!(tariff_index, id = ?tariff.id(), "Found active tariff");
818    debug!(%timezone, "Found timezone");
819
820    // Convert the CDRs periods to the API input period.
821    let periods = charging_periods.into_iter().map(Period::from).collect();
822
823    let periods = normalize_periods(periods, end_date_time, timezone);
824    let price_cdr_report = price_periods(&periods, &tariff)
825        .with_element(cdr_elem.as_element())?
826        .gather_warnings_into(&mut warnings);
827
828    if tariff.has_reservation_elements() {
829        warnings.insert(
830            cdr_elem.as_element(),
831            Warning::Tariff(crate::tariff::Warning::ReservationElementSkipped),
832        );
833    }
834
835    let mut report = generate_report(
836        &cdr_totals,
837        timezone,
838        tariff_reports,
839        price_cdr_report,
840        TariffOrigin {
841            index: tariff_index,
842            id: tariff.id().to_owned(),
843            currency: tariff.currency(),
844        },
845    );
846
847    if let Some(total_cost) = report.total_cost.calculated.as_mut() {
848        if let Some(min_price) = tariff.min_price() {
849            if *total_cost < min_price {
850                *total_cost = min_price;
851                warnings.insert(
852                    cdr_elem.as_element(),
853                    crate::tariff::Warning::TotalCostClampedToMin.into(),
854                );
855            }
856        }
857
858        if let Some(max_price) = tariff.max_price() {
859            if *total_cost > max_price {
860                *total_cost = max_price;
861                warnings.insert(
862                    cdr_elem.as_element(),
863                    crate::tariff::Warning::TotalCostClampedToMax.into(),
864                );
865            }
866        }
867    }
868
869    Ok(report.into_caveat(warnings))
870}
871
872/// Price a list of normalized [`Period`]s using a [`VersionedJson`](crate::tariff::VersionedJson) tariff.
873pub(crate) fn periods(
874    end_date_time: DateTime<Utc>,
875    timezone: Tz,
876    tariff_elem: &crate::tariff::v221::Tariff<'_>,
877    mut periods: Vec<Period>,
878) -> VerdictDeferred<PeriodsReport> {
879    // Make sure the periods are sorted by time as the start date of one period determines the end
880    // date of the previous period.
881    periods.sort_by_key(|p| p.start_date_time);
882    let tariff = Tariff::from_v221(tariff_elem);
883    let periods = normalize_periods(periods, end_date_time, timezone);
884    price_periods(&periods, &tariff)
885}
886
887fn normalize_periods(
888    periods: Vec<Period>,
889    end_date_time: DateTime<Utc>,
890    local_timezone: Tz,
891) -> Vec<PeriodNormalized> {
892    debug!("Normalizing CDR periods");
893
894    // Each new period is linked to the previous periods data.
895    let mut previous_end_snapshot = Option::<TotalsSnapshot>::None;
896
897    // The end-date of the first period is the start-date of the second and so on.
898    let end_dates = {
899        let mut end_dates = periods
900            .iter()
901            .skip(1)
902            .map(|p| p.start_date_time)
903            .collect::<Vec<_>>();
904
905        // The last end-date is the end-date of the CDR.
906        end_dates.push(end_date_time);
907        end_dates
908    };
909
910    let periods = periods
911        .into_iter()
912        .zip(end_dates)
913        .enumerate()
914        .map(|(index, (period, end_date_time))| {
915            trace!(index, "processing\n{period:#?}");
916            let Period {
917                start_date_time,
918                consumed,
919            } = period;
920
921            let period = if let Some(prev_end_snapshot) = previous_end_snapshot.take() {
922                let start_snapshot = prev_end_snapshot;
923                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
924
925                let period = PeriodNormalized {
926                    consumed,
927                    start_snapshot,
928                    end_snapshot,
929                };
930                trace!("Adding new period based on the last added\n{period:#?}");
931                period
932            } else {
933                let start_snapshot = TotalsSnapshot::zero(start_date_time, local_timezone);
934                let end_snapshot = start_snapshot.next(&consumed, end_date_time);
935
936                let period = PeriodNormalized {
937                    consumed,
938                    start_snapshot,
939                    end_snapshot,
940                };
941                trace!("Adding new period\n{period:#?}");
942                period
943            };
944
945            previous_end_snapshot.replace(period.end_snapshot.clone());
946            period
947        })
948        .collect::<Vec<_>>();
949
950    periods
951}
952
953/// Price the given set of CDR periods using a normalized `Tariff`.
954fn price_periods(periods: &[PeriodNormalized], tariff: &Tariff) -> VerdictDeferred<PeriodsReport> {
955    debug!(count = periods.len(), "Pricing CDR periods");
956
957    if tracing::enabled!(tracing::Level::TRACE) {
958        trace!("# CDR period list:");
959        for period in periods {
960            trace!("{period:#?}");
961        }
962    }
963
964    let period_totals = period_totals(periods, tariff);
965    let (billed, mut warnings) = period_totals.calculate_billed()?.into_parts();
966
967    if tariff.has_reservation_elements() {
968        warnings.insert(Warning::Tariff(
969            crate::tariff::Warning::ReservationElementSkipped,
970        ));
971    }
972
973    let (billable, periods, totals) = billed;
974    let total_costs = total_costs(&periods, tariff);
975    let report = PeriodsReport {
976        billable,
977        periods,
978        totals,
979        total_costs,
980    };
981
982    Ok(report.into_caveat_deferred(warnings))
983}
984
985/// The internal report generated from the [`periods`] fn.
986pub(crate) struct PeriodsReport {
987    /// The billable dimensions calculated by applying the step-size to each dimension.
988    pub billable: Billable,
989
990    /// A list of reports for each charging period that occurred during a session.
991    pub periods: Vec<PeriodReport>,
992
993    /// The totals for each dimension.
994    pub totals: Totals,
995
996    /// The total costs for each dimension.
997    pub total_costs: TotalCosts,
998}
999
1000/// A report for a single charging period that occurred during a session.
1001///
1002/// A charging period is a period of time that has relevance for the total costs of a CDR.
1003/// During a charging session, different parameters change all the time, like the amount of energy used,
1004/// or the time of day. These changes can result in another [`PriceComponent`](https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#142-pricecomponent-class) of the Tariff becoming active.
1005#[derive(Debug)]
1006pub struct PeriodReport {
1007    /// The start time of this period.
1008    pub start_date_time: DateTime<Utc>,
1009
1010    /// The end time of this period.
1011    pub end_date_time: DateTime<Utc>,
1012
1013    /// A structure that contains results per dimension.
1014    pub dimensions: Dimensions,
1015}
1016
1017impl PeriodReport {
1018    /// The total cost of all dimensions in this period.
1019    pub fn cost(&self) -> Option<Price> {
1020        [
1021            self.dimensions
1022                .duration_charging
1023                .as_ref()
1024                .and_then(Dimension::cost),
1025            self.dimensions
1026                .duration_idle
1027                .as_ref()
1028                .and_then(Dimension::cost),
1029            self.dimensions.flat.cost(),
1030            self.dimensions.energy.as_ref().and_then(Dimension::cost),
1031        ]
1032        .into_iter()
1033        .fold(None, |accum, next| {
1034            if accum.is_none() && next.is_none() {
1035                None
1036            } else {
1037                Some(
1038                    accum
1039                        .unwrap_or_default()
1040                        .saturating_add(next.unwrap_or_default()),
1041                )
1042            }
1043        })
1044    }
1045}
1046
1047/// A [`PeriodReport`] under construction during the step-size calculation.
1048///
1049/// After step sizes are applied the step-size fields are dropped and this converts into
1050/// a [`PeriodReport`].
1051#[derive(Debug)]
1052struct PeriodReportScratch {
1053    start_date_time: DateTime<Utc>,
1054    end_date_time: DateTime<Utc>,
1055    dimensions: Dimensions,
1056    step_size_duration_charging: Option<Component>,
1057    step_size_duration_idle: Option<Component>,
1058    step_size_energy: Option<Component>,
1059}
1060
1061impl From<PeriodReportScratch> for PeriodReport {
1062    fn from(scratch: PeriodReportScratch) -> Self {
1063        Self {
1064            start_date_time: scratch.start_date_time,
1065            end_date_time: scratch.end_date_time,
1066            dimensions: scratch.dimensions,
1067        }
1068    }
1069}
1070
1071/// The result of normalizing the CDR charging periods.
1072#[derive(Debug)]
1073struct PeriodTotals {
1074    /// The list of periods under construction.
1075    periods: Vec<PeriodReportScratch>,
1076
1077    /// The totals for each dimension.
1078    totals: Totals,
1079}
1080
1081/// The totals for each dimension.
1082#[derive(Debug, Default)]
1083pub(crate) struct Totals {
1084    /// The total energy used during a session.
1085    pub energy: Option<Kwh>,
1086
1087    /// The total charging time used during a session.
1088    ///
1089    /// Some if the charging happened during the session.
1090    pub duration_charging: Option<TimeDelta>,
1091
1092    /// The total idle time during a session.
1093    ///
1094    /// Some if there was idle time during the session.
1095    pub duration_idle: Option<TimeDelta>,
1096}
1097
1098impl PeriodTotals {
1099    /// Apply step sizes and convert scratch periods into final [`PeriodReport`]s.
1100    fn calculate_billed(self) -> VerdictDeferred<(Billable, Vec<PeriodReport>, Totals)> {
1101        let mut warnings = warning::SetDeferred::new();
1102        let Self {
1103            mut periods,
1104            totals,
1105        } = self;
1106
1107        let billable =
1108            apply_step_sizes(&mut periods, &totals)?.gather_deferred_warnings_into(&mut warnings);
1109
1110        let periods = periods.into_iter().map(PeriodReport::from).collect();
1111
1112        Ok((billable, periods, totals).into_caveat_deferred(warnings))
1113    }
1114}
1115
1116/// The billable dimensions calculated by applying the step-size to each dimension.
1117#[derive(Debug)]
1118pub(crate) struct Billable {
1119    /// The billable charging time.
1120    duration_charging: Option<TimeDelta>,
1121
1122    /// The billable idle time.
1123    duration_idle: Option<TimeDelta>,
1124
1125    /// The billable energy use.
1126    energy: Option<Kwh>,
1127}
1128
1129/// Map the `session::ChargePeriod`s to a normalized `Period` and calculate the step size and
1130/// totals for each dimension.
1131fn period_totals(periods: &[PeriodNormalized], tariff: &Tariff) -> PeriodTotals {
1132    let mut has_flat_fee = false;
1133    let mut totals = Totals::default();
1134
1135    debug!(
1136        tariff_id = tariff.id(),
1137        period_count = periods.len(),
1138        "Accumulating dimension totals for each period"
1139    );
1140
1141    let periods = periods
1142        .iter()
1143        .enumerate()
1144        .map(|(index, period)| {
1145            let mut component_set = tariff.active_components(period);
1146            trace!(
1147                index,
1148                "Creating charge period with Dimension\n{period:#?}\n{component_set:#?}"
1149            );
1150
1151            if component_set.flat.is_some() {
1152                if has_flat_fee {
1153                    component_set.flat = None;
1154                } else {
1155                    has_flat_fee = true;
1156                }
1157            }
1158
1159            // Extract step-size components before consuming the component_set.
1160            let step_size_duration_charging = if period.consumed.duration_charging.is_some() {
1161                component_set.duration_charging.clone()
1162            } else {
1163                None
1164            };
1165            let step_size_duration_idle = if period.consumed.duration_idle.is_some() {
1166                component_set.duration_idle.clone()
1167            } else {
1168                None
1169            };
1170            let step_size_energy = if period.consumed.energy.is_some() {
1171                component_set.energy.clone()
1172            } else {
1173                None
1174            };
1175
1176            let dimensions = Dimensions::new(component_set, &period.consumed);
1177
1178            trace!(period_index = index, "Dimensions created\n{dimensions:#?}");
1179
1180            if let Some(dim) = &dimensions.duration_charging {
1181                let acc = totals.duration_charging.get_or_insert_default();
1182                *acc = acc.saturating_add(dim.volume);
1183            }
1184
1185            if let Some(dim) = &dimensions.energy {
1186                let acc = totals.energy.get_or_insert_default();
1187                *acc = acc.saturating_add(dim.volume);
1188            }
1189
1190            if let Some(dim) = &dimensions.duration_idle {
1191                let acc = totals.duration_idle.get_or_insert_default();
1192                *acc = acc.saturating_add(dim.volume);
1193            }
1194
1195            trace!(period_index = index, ?totals, "Update totals");
1196
1197            PeriodReportScratch {
1198                start_date_time: period.start_snapshot.date_time,
1199                end_date_time: period.end_snapshot.date_time,
1200                dimensions,
1201                step_size_duration_charging,
1202                step_size_duration_idle,
1203                step_size_energy,
1204            }
1205        })
1206        .collect::<Vec<_>>();
1207
1208    PeriodTotals { periods, totals }
1209}
1210
1211/// The total costs for each dimension.
1212#[derive(Debug, Default)]
1213pub(crate) struct TotalCosts {
1214    /// The [`Price`] for all energy used during a session.
1215    pub energy: Option<Price>,
1216
1217    /// The [`Price`] for all flat rates applied during a session.
1218    pub fixed: Option<Price>,
1219
1220    /// The [`Price`] for all charging time used during a session.
1221    pub duration_charging: Option<Price>,
1222
1223    /// The [`Price`] for all idle time used during a session.
1224    pub duration_idle: Option<Price>,
1225}
1226
1227impl TotalCosts {
1228    /// Summate each dimension total into a single total.
1229    ///
1230    /// Return `None` if there are no cost dimensions otherwise return `Some`.
1231    pub(crate) fn total(&self) -> Option<Price> {
1232        let Self {
1233            energy,
1234            fixed,
1235            duration_charging,
1236            duration_idle,
1237        } = self;
1238        debug!(
1239            energy = %DisplayOption(*energy),
1240            fixed = %DisplayOption(*fixed),
1241            duration_charging = %DisplayOption(*duration_charging),
1242            duration_idle = %DisplayOption(*duration_idle),
1243            "Calculating total costs."
1244        );
1245        [energy, fixed, duration_charging, duration_idle]
1246            .into_iter()
1247            .fold(None, |accum: Option<Price>, next| match (accum, next) {
1248                (None, None) => None,
1249                _ => Some(
1250                    accum
1251                        .unwrap_or_default()
1252                        .saturating_add(next.unwrap_or_default()),
1253                ),
1254            })
1255    }
1256}
1257
1258/// Accumulate total costs per dimension across all periods.
1259fn total_costs(periods: &[PeriodReport], tariff: &Tariff) -> TotalCosts {
1260    let mut total_costs = TotalCosts::default();
1261
1262    debug!(
1263        tariff_id = tariff.id(),
1264        period_count = periods.len(),
1265        "Accumulating dimension costs for each period"
1266    );
1267    for (index, period) in periods.iter().enumerate() {
1268        let dimensions = &period.dimensions;
1269
1270        trace!(period_index = index, "Processing period");
1271
1272        let energy_cost = dimensions.energy.as_ref().and_then(Dimension::cost);
1273        let fixed_cost = dimensions.flat.cost();
1274        let duration_charging_cost = dimensions
1275            .duration_charging
1276            .as_ref()
1277            .and_then(Dimension::cost);
1278        let duration_idle_cost = dimensions.duration_idle.as_ref().and_then(Dimension::cost);
1279
1280        trace!(?total_costs.energy, ?energy_cost, "Energy cost");
1281        trace!(?total_costs.duration_charging, ?duration_charging_cost, "Charging cost");
1282        trace!(?total_costs.duration_idle, ?duration_idle_cost, "Idle cost");
1283        trace!(?total_costs.fixed, ?fixed_cost, "Fixed cost");
1284
1285        total_costs.energy = match (total_costs.energy, energy_cost) {
1286            (None, None) => None,
1287            (total, period) => Some(
1288                total
1289                    .unwrap_or_default()
1290                    .saturating_add(period.unwrap_or_default()),
1291            ),
1292        };
1293
1294        total_costs.duration_charging =
1295            match (total_costs.duration_charging, duration_charging_cost) {
1296                (None, None) => None,
1297                (total, period) => Some(
1298                    total
1299                        .unwrap_or_default()
1300                        .saturating_add(period.unwrap_or_default()),
1301                ),
1302            };
1303
1304        total_costs.duration_idle = match (total_costs.duration_idle, duration_idle_cost) {
1305            (None, None) => None,
1306            (total, period) => Some(
1307                total
1308                    .unwrap_or_default()
1309                    .saturating_add(period.unwrap_or_default()),
1310            ),
1311        };
1312
1313        total_costs.fixed = match (total_costs.fixed, fixed_cost) {
1314            (None, None) => None,
1315            (total, period) => Some(
1316                total
1317                    .unwrap_or_default()
1318                    .saturating_add(period.unwrap_or_default()),
1319            ),
1320        };
1321
1322        trace!(period_index = index, ?total_costs, "Update totals");
1323    }
1324
1325    total_costs
1326}
1327
1328fn generate_report(
1329    cdr_totals: &v221::cdr::Totals,
1330    timezone: Tz,
1331    tariff_reports: Vec<TariffReport>,
1332    price_periods_report: PeriodsReport,
1333    tariff_used: TariffOrigin,
1334) -> Report {
1335    let PeriodsReport {
1336        billable,
1337        periods,
1338        totals,
1339        total_costs,
1340    } = price_periods_report;
1341    trace!("Update billed totals {billable:#?}");
1342
1343    let total_cost = total_costs.total();
1344
1345    debug!(total_cost = %DisplayOption(total_cost.as_ref()));
1346
1347    let total_time = {
1348        debug!(
1349            period_start = %DisplayOption(periods.first().map(|p| p.start_date_time)),
1350            period_end = %DisplayOption(periods.last().map(|p| p.end_date_time)),
1351            "Calculating `total_time`"
1352        );
1353
1354        periods
1355            .first()
1356            .zip(periods.last())
1357            .map(|(first, last)| {
1358                last.end_date_time
1359                    .signed_duration_since(first.start_date_time)
1360            })
1361            .unwrap_or_default()
1362    };
1363    debug!(total_time = %Hms(total_time));
1364
1365    let report = Report {
1366        periods,
1367        tariff_used,
1368        timezone: timezone.to_string(),
1369        billed_idle_time: billable.duration_idle,
1370        billed_energy: billable.energy.round_to_ocpi_scale(),
1371        billed_charging_time: billable.duration_charging,
1372        tariff_reports,
1373        total_charging_time: totals.duration_charging,
1374        total_cost: Total {
1375            cdr: cdr_totals.cost.round_to_ocpi_scale(),
1376            calculated: total_cost.round_to_ocpi_scale(),
1377        },
1378        total_charging_time_cost: Total {
1379            cdr: cdr_totals.duration_charging_cost.round_to_ocpi_scale(),
1380            calculated: total_costs.duration_charging.round_to_ocpi_scale(),
1381        },
1382        total_time: Total {
1383            cdr: cdr_totals.duration_charging,
1384            calculated: total_time,
1385        },
1386        total_idle_cost: Total {
1387            cdr: cdr_totals.duration_idle_cost.round_to_ocpi_scale(),
1388            calculated: total_costs.duration_idle.round_to_ocpi_scale(),
1389        },
1390        total_idle_time: Total {
1391            cdr: cdr_totals.duration_idle,
1392            calculated: totals.duration_idle,
1393        },
1394        total_energy_cost: Total {
1395            cdr: cdr_totals.energy_cost.round_to_ocpi_scale(),
1396            calculated: total_costs.energy.round_to_ocpi_scale(),
1397        },
1398        total_energy: Total {
1399            cdr: cdr_totals.energy.round_to_ocpi_scale(),
1400            calculated: totals.energy.round_to_ocpi_scale(),
1401        },
1402        total_fixed_cost: Total {
1403            cdr: cdr_totals.fixed_cost.round_to_ocpi_scale(),
1404            calculated: total_costs.fixed.round_to_ocpi_scale(),
1405        },
1406    };
1407
1408    trace!("{report:#?}");
1409
1410    report
1411}
1412
1413/// Apply step sizes by iterating in reverse over the scratch periods to find the last period
1414/// that had an active component for each dimension, then mutating its `billed_volume`.
1415fn apply_step_sizes(
1416    periods: &mut [PeriodReportScratch],
1417    totals: &Totals,
1418) -> VerdictDeferred<Billable> {
1419    let mut warnings = warning::SetDeferred::new();
1420
1421    let has_idle_step_size = periods.iter().any(|p| p.step_size_duration_idle.is_some());
1422
1423    let duration_charging = if let Some(total) = totals.duration_charging {
1424        let mut result = Some(total);
1425        for period in periods.iter_mut().rev() {
1426            let Some(step) = period
1427                .step_size_duration_charging
1428                .as_ref()
1429                .map(|c| c.step_size)
1430            else {
1431                continue;
1432            };
1433            if has_idle_step_size {
1434                result = Some(total);
1435            } else if let Some(dim) = period.dimensions.duration_charging.as_mut() {
1436                let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1437                    .gather_deferred_warnings_into(&mut warnings);
1438                result = Some(dt);
1439            }
1440            break;
1441        }
1442        result
1443    } else {
1444        None
1445    };
1446
1447    let duration_idle = if let Some(total) = totals.duration_idle {
1448        let mut result = Some(total);
1449        for period in periods.iter_mut().rev() {
1450            let Some(step) = period.step_size_duration_idle.as_ref().map(|c| c.step_size) else {
1451                continue;
1452            };
1453            if let Some(dim) = period.dimensions.duration_idle.as_mut() {
1454                let dt = duration_step_size(total, &mut dim.billed_volume, step)?
1455                    .gather_deferred_warnings_into(&mut warnings);
1456                result = Some(dt);
1457            }
1458            break;
1459        }
1460        result
1461    } else {
1462        None
1463    };
1464
1465    let energy = if let Some(total) = totals.energy {
1466        let mut result = Some(total);
1467        for period in periods.iter_mut().rev() {
1468            let Some(step) = period.step_size_energy.as_ref().map(|c| c.step_size) else {
1469                continue;
1470            };
1471            if step == 0 {
1472                result = Some(total);
1473            } else {
1474                let step_dec = Decimal::from(step);
1475                if let Some(dim) = period.dimensions.energy.as_mut() {
1476                    let Some(watt_hours) = total.watt_hours().checked_div(step_dec) else {
1477                        return warnings.bail(duration::Warning::Overflow.into());
1478                    };
1479                    let total_billed_volume =
1480                        Kwh::from_watt_hours(watt_hours.ceil().saturating_mul(step_dec));
1481                    let period_delta_volume = total_billed_volume.saturating_sub(total);
1482                    dim.billed_volume = dim.billed_volume.saturating_add(period_delta_volume);
1483                    result = Some(total_billed_volume);
1484                }
1485            }
1486            break;
1487        }
1488        result
1489    } else {
1490        None
1491    };
1492
1493    Ok(Billable {
1494        duration_charging,
1495        duration_idle,
1496        energy,
1497    }
1498    .into_caveat_deferred(warnings))
1499}
1500
1501/// Return the duration as a `Decimal` amount of seconds.
1502fn delta_as_seconds_dec(delta: TimeDelta) -> Decimal {
1503    Decimal::from(delta.num_milliseconds())
1504        .checked_div(Decimal::from(duration::MILLIS_IN_SEC))
1505        .expect("Can't overflow; See test `as_seconds_dec_should_not_overflow`")
1506}
1507
1508/// Create a `HoursDecimal` from a `Decimal` amount of seconds.
1509fn delta_from_seconds_dec(seconds: Decimal) -> VerdictDeferred<TimeDelta> {
1510    let millis = seconds.saturating_mul(Decimal::from(duration::MILLIS_IN_SEC));
1511    let Ok(millis) = i64::try_from(millis) else {
1512        return Err(warning::ErrorSetDeferred::with_warn(
1513            duration::Warning::Overflow.into(),
1514        ));
1515    };
1516    let Some(delta) = TimeDelta::try_milliseconds(millis) else {
1517        return Err(warning::ErrorSetDeferred::with_warn(
1518            duration::Warning::Overflow.into(),
1519        ));
1520    };
1521    Ok(delta.into_caveat_deferred(warning::SetDeferred::new()))
1522}
1523
1524/// Apply a duration based step size for either `time` or `idle_time`.
1525fn duration_step_size(
1526    total_volume: TimeDelta,
1527    period_billed_volume: &mut TimeDelta,
1528    step_size: u64,
1529) -> VerdictDeferred<TimeDelta> {
1530    if step_size == 0 {
1531        return Ok(total_volume.into_caveat_deferred(warning::SetDeferred::new()));
1532    }
1533
1534    let total_seconds = delta_as_seconds_dec(total_volume);
1535    let step_size = Decimal::from(step_size);
1536
1537    let Some(x) = total_seconds.checked_div(step_size) else {
1538        return Err(warning::ErrorSetDeferred::with_warn(
1539            duration::Warning::Overflow.into(),
1540        ));
1541    };
1542    let total_billed_volume = delta_from_seconds_dec(x.ceil().saturating_mul(step_size))?;
1543
1544    let period_delta_volume = total_billed_volume.saturating_sub(total_volume);
1545    *period_billed_volume = period_billed_volume.saturating_add(period_delta_volume);
1546
1547    Ok(total_billed_volume)
1548}