ocpi-tariffs 0.52.0

OCPI tariff calculations
Documentation
#[cfg(test)]
mod test_from_schema;

use std::convert::Infallible;

use chrono::{DateTime, TimeDelta, Utc};
use rust_decimal::Decimal;

use crate::{
    number::FromDecimal as _,
    price::{v221, Warning},
    schema,
    warning::{self, GatherWarnings as _, IntoCaveat as _, IntoInfallible as _},
    FromSchema, Kwh, Money, Price, ToDuration, Verdict,
};

/// The CDR object describes the Charging Session and its costs. How these costs are build up etc.
///
/// See: [OCPI spec 2.1.1 spec: CDR]<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_cdrs.md>.
#[derive(Clone, Debug)]
pub(crate) struct Cdr {
    /// Start timestamp of the charging session.
    start_date_time: DateTime<Utc>,

    /// Stop timestamp of the charging session.
    stop_date_time: DateTime<Utc>,

    /// List of charging periods that make up this charging session> A session should consist of 1 or
    /// more periods, where each period has a different relevant Tariff.
    charging_periods: Vec<ChargingPeriod>,

    /// Total cost of this transaction.
    total_cost: Decimal,

    /// Total energy charged, in kWh.
    total_energy: Decimal,

    /// Total time charging, in hours.
    duration_charging: TimeDelta,

    /// Total time not charging, in hours.
    duration_idle: Option<TimeDelta>,
}

/// The volume that has been consumed for a specific dimension during a charging period.
#[derive(Debug, Clone)]
struct Dimension {
    /// The dimension type.
    dimension_type: DimensionType,

    /// Volume of the dimension consumed, measured according to the dimension type.
    volume: Decimal,
}

/// The volume that has been consumed for a specific dimension during a charging period.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum DimensionType {
    /// Consumed energy in `kWh`.
    Energy,

    /// Flat fee, no unit.
    Flat,

    /// The peak current, in `A`, during this period.
    MaxCurrent,

    /// The lowest current, in `A`, during this period.
    MinCurrent,

    /// The parking time, in hours, consumed in this period.
    ParkingTime,

    /// The charging time, in hours, consumed in this period.
    Time,
}

impl FromSchema<'_, schema::v211::CdrDimensionType> for DimensionType {
    type Warning = Infallible;

    fn from_schema(source: &schema::v211::CdrDimensionType) -> Verdict<Self, Self::Warning> {
        use schema::v211::CdrDimensionType;

        let v = match source {
            CdrDimensionType::Energy => Self::Energy,
            CdrDimensionType::Flat => Self::Flat,
            CdrDimensionType::MaxCurrent => Self::MaxCurrent,
            CdrDimensionType::MinCurrent => Self::MinCurrent,
            CdrDimensionType::ParkingTime => Self::ParkingTime,
            CdrDimensionType::Time => Self::Time,
        };

        Ok(v.into_infallible_caveat())
    }
}

/// A single charging period, containing a nonempty list of charge dimensions.
#[derive(Clone, Debug)]
struct ChargingPeriod {
    /// Start timestamp of the charging period. This period ends when a next period starts, the
    /// last period ends when the session ends.
    start_date_time: DateTime<Utc>,

    /// List of relevant values for this charging period.
    dimensions: Vec<Dimension>,
}

impl From<Cdr> for v221::Cdr {
    fn from(cdr: Cdr) -> Self {
        let Cdr {
            start_date_time,
            stop_date_time,
            charging_periods,
            total_cost,
            total_energy,
            duration_charging,
            duration_idle,
        } = cdr;

        Self {
            end_date_time: stop_date_time,
            start_date_time,
            charging_periods: charging_periods
                .into_iter()
                .map(ChargingPeriod::into)
                .collect(),
            totals: v221::cdr::Totals {
                cost: Price {
                    excl_vat: Money::from_decimal(total_cost),
                    // The v211 tariffs can't determine VAT.
                    incl_vat: None,
                },
                energy: Kwh::from_decimal(total_energy),
                energy_cost: None,
                duration_charging,
                duration_charging_cost: None,
                fixed_cost: None,
                duration_idle,
                duration_idle_cost: None,
            },
        }
    }
}

impl From<ChargingPeriod> for v221::cdr::ChargingPeriod {
    fn from(period: ChargingPeriod) -> Self {
        let ChargingPeriod {
            start_date_time,
            dimensions,
        } = period;
        let dimensions = dimensions
            .into_iter()
            .filter_map(|d| {
                let Dimension {
                    dimension_type,
                    volume,
                } = d;

                if let DimensionType::Flat = dimension_type {
                    // We can safely ignore the flat dimension since this can be determined from the tariff and
                    // period time-stamps.
                    return None;
                }

                let dimension_type = match dimension_type {
                    DimensionType::Energy => v221::cdr::DimensionType::Energy,
                    DimensionType::MaxCurrent => v221::cdr::DimensionType::MaxCurrent,
                    DimensionType::MinCurrent => v221::cdr::DimensionType::MinCurrent,
                    DimensionType::ParkingTime => v221::cdr::DimensionType::ParkingTime,
                    DimensionType::Time => v221::cdr::DimensionType::Time,
                    DimensionType::Flat => return None,
                };

                Some(v221::cdr::Dimension {
                    dimension_type,
                    volume,
                })
            })
            .collect();
        Self {
            start_date_time,
            dimensions,
        }
    }
}

impl<'buf> FromSchema<'buf, schema::v211::Cdr<'buf>> for Cdr {
    type Warning = Warning;

    /// Lower the `v2.1.1` CDR IR into a structured CDR.
    ///
    /// The `currency` the IR carries is not read: a `v2.1.1` CDR is priced by the tariffs it
    /// carries, and the schema walk already checks the field's shape.
    ///
    /// The whole-CDR checks are made by [`cdr::Versioned::to_v221`](crate::cdr::Versioned),
    /// which has an element to anchor them to.
    fn from_schema(source: &schema::v211::Cdr<'buf>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::new();

        let start_date_time = warnings.ok_or_bail(&source.start_date_time)?;
        let start_date_time =
            DateTime::<Utc>::from_schema(start_date_time)?.gather_warnings_into(&mut warnings);

        let stop_date_time = warnings.ok_or_bail(&source.stop_date_time)?;
        let stop_date_time =
            DateTime::<Utc>::from_schema(stop_date_time)?.gather_warnings_into(&mut warnings);

        // A period the schema could not build rejects the CDR rather than being skipped: a
        // CDR missing one of its periods is validated against the wrong consumption amounts.
        let periods = warnings.ok_or_bail(&source.charging_periods)?;
        let mut charging_periods = Vec::with_capacity(periods.len());
        for period in periods {
            let period = warnings.ok_or_bail(period)?;
            charging_periods
                .push(ChargingPeriod::from_schema(period)?.gather_warnings_into(&mut warnings));
        }

        // Pricing presumes the periods are in order. The CDR is not required to list them sorted.
        charging_periods.sort_unstable_by_key(|period| period.start_date_time);

        let total_cost = warnings.ok_or_bail(&source.total_cost)?;
        let total_cost = Decimal::from_schema(total_cost)?.gather_warnings_into(&mut warnings);

        let total_energy = warnings.ok_or_bail(&source.total_energy)?;
        let total_energy = Decimal::from_schema(total_energy)?.gather_warnings_into(&mut warnings);

        let total_time = warnings.ok_or_bail(&source.total_time)?;
        let total_time = Decimal::from_schema(total_time)?.gather_warnings_into(&mut warnings);

        let total_parking_time = warnings.ok_or_bail(&source.total_parking_time)?;
        let total_parking_time = total_parking_time
            .as_ref()
            .map(Decimal::from_schema)
            .transpose()?
            .gather_warnings_into(&mut warnings);

        let cdr = Cdr {
            start_date_time,
            stop_date_time,
            charging_periods,
            total_cost,
            total_energy,
            duration_charging: total_time.to_duration(),
            duration_idle: total_parking_time.as_ref().map(ToDuration::to_duration),
        };

        Ok(cdr.into_caveat(warnings))
    }
}

impl<'buf> FromSchema<'buf, schema::v211::ChargingPeriod<'buf>> for ChargingPeriod {
    type Warning = Warning;

    fn from_schema(source: &schema::v211::ChargingPeriod<'buf>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::new();

        let start_date_time = warnings.ok_or_bail(&source.start_date_time)?;
        let start_date_time =
            DateTime::<Utc>::from_schema(start_date_time)?.gather_warnings_into(&mut warnings);

        // `dimensions` is required; a period that measures nothing is rejected.
        let dimensions = warnings.ok_or_bail(&source.dimensions)?;

        // A dimension lowers to `None` when its `type` is unknown, which drops it as
        // unusable. A dimension the schema could not build at all is a different case: the
        // period is rejected rather than silently priced without one of its volumes.
        let mut lowered = Vec::with_capacity(dimensions.len());
        for dimension in dimensions {
            let dimension = warnings.ok_or_bail(dimension)?;
            let dimension =
                Option::<Dimension>::from_schema(dimension)?.gather_warnings_into(&mut warnings);

            if let Some(dimension) = dimension {
                lowered.push(dimension);
            }
        }

        let period = ChargingPeriod {
            start_date_time,
            dimensions: lowered,
        };

        Ok(period.into_caveat(warnings))
    }
}

impl<'buf> FromSchema<'buf, schema::v211::Dimension<'buf>> for Option<Dimension> {
    type Warning = Warning;

    fn from_schema(source: &schema::v211::Dimension<'buf>) -> Verdict<Self, Self::Warning> {
        let mut warnings = warning::Set::new();

        // An unknown (or wrong-typed) `type` leaves the volume with no meaning, so the
        // dimension is dropped.
        if let schema::Integrity::Err(_) = &source.dimension_type {
            return Ok(None.into_caveat(warnings));
        }
        let dimension_type = warnings.ok_or_bail(&source.dimension_type)?;
        let dimension_type = DimensionType::from_schema(&dimension_type.value()).into_infallible();

        let volume = warnings.ok_or_bail(&source.volume)?;
        let volume = Decimal::from_schema(volume)?.gather_warnings_into(&mut warnings);

        let dimension = Dimension {
            dimension_type,
            volume,
        };

        Ok(Some(dimension).into_caveat(warnings))
    }
}