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 super::super::Consumed;
use crate::{
    country, currency,
    number::FromDecimal as _,
    price::{Period, Warning},
    schema::{self, HasElement as _},
    string,
    warning::{self, GatherWarnings as _, IntoCaveat as _, IntoInfallible as _},
    Ampere, FromSchema, Kw, Kwh, Price, ToDuration as _, Verdict,
};

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

    /// Stop timestamp of the charging session.
    pub end_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.
    pub charging_periods: Vec<ChargingPeriod>,

    pub totals: Totals,
}

#[derive(Debug)]
pub struct Totals {
    /// Total cost of this transaction.
    pub cost: Price,

    /// Total cost of the flat dimension.
    pub fixed_cost: Option<Price>,

    /// Total energy charged, in kWh.
    pub energy: Kwh,

    /// Total cost related to the energy dimension.
    pub energy_cost: Option<Price>,

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

    /// Total cost related to the charging time dimension.
    pub duration_charging_cost: Option<Price>,

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

    /// Total cost related to the `parking`/idle time dimension.
    pub duration_idle_cost: Option<Price>,
}

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

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

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

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

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

    /// The maximum power, in 'kW', reached during this period.
    MaxPower,

    /// The minimum power, in 'kW', reached during this period.
    MinPower,

    /// The parking time, in hours, consumed in this period.
    ///
    /// NOTE: We use the term `idle` in variables and fields instead of `parking` to avoid confusion
    /// in the definition of parking. The OCPI spec defines `parking` as the time spent not charging.
    ParkingTime,

    /// The reservation time, in hours, consumed in this period.
    ReservationTime,

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

    Current,

    EnergyExport,

    EnergyImport,

    Power,

    StateOfCharge,
}

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

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

        let v = match source {
            CdrDimensionType::Energy => Self::Energy,
            CdrDimensionType::MaxCurrent => Self::MaxCurrent,
            CdrDimensionType::MinCurrent => Self::MinCurrent,
            CdrDimensionType::ParkingTime => Self::ParkingTime,
            CdrDimensionType::Time => Self::Time,
            CdrDimensionType::Current => Self::Current,
            CdrDimensionType::EnergyExport => Self::EnergyExport,
            CdrDimensionType::EnergyImport => Self::EnergyImport,
            CdrDimensionType::MaxPower => Self::MaxPower,
            CdrDimensionType::MinPower => Self::MinPower,
            CdrDimensionType::Power => Self::Power,
            CdrDimensionType::ReservationTime => Self::ReservationTime,
            CdrDimensionType::StateOfCharge => Self::StateOfCharge,
        };

        Ok(v.into_infallible_caveat())
    }
}

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

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

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

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

        // `country_code`, `currency` and `party_id` are not used while pricing, but the schema
        // only checks their shape, so their contents are still worth a warning. None of them
        // can stop a CDR being priced.
        if let schema::Integrity::Ok(country_code) = &source.country_code {
            let code_set =
                country::CodeSet::from_schema(country_code).gather_warnings_into(&mut warnings)?;

            if let country::CodeSet::Alpha3(_) = code_set {
                warnings.insert(country_code.element(), Warning::CountryShouldBeAlpha2);
            }
        }

        if let schema::Integrity::Ok(currency) = &source.currency {
            let _ignore_value =
                currency::Code::from_schema(currency).gather_warnings_into(&mut warnings);
        }

        if let schema::Integrity::Ok(party_id) = &source.party_id {
            let _ignore_value = string::CiExactLen::<'_, 3>::from_schema(party_id)
                .gather_warnings_into(&mut warnings);
        }

        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 end_date_time = warnings.ok_or_bail(&source.end_date_time)?;
        let end_date_time =
            DateTime::<Utc>::from_schema(end_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 = Price::from_schema(total_cost)?.gather_warnings_into(&mut warnings);

        let total_energy = warnings.ok_or_bail(&source.total_energy)?;
        let total_energy = Kwh::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 total_fixed_cost = warnings.ok_or_bail(&source.total_fixed_cost)?;
        let total_fixed_cost = total_fixed_cost
            .as_ref()
            .map(Price::from_schema)
            .transpose()?
            .gather_warnings_into(&mut warnings);

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

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

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

        let cdr = Cdr {
            start_date_time,
            end_date_time,
            charging_periods,
            totals: Totals {
                cost: total_cost,
                fixed_cost: total_fixed_cost,
                energy: total_energy,
                energy_cost: total_energy_cost,
                duration_charging: total_time.to_duration(),
                duration_charging_cost: total_time_cost,
                duration_idle: total_parking_time.map(|d| d.to_duration()),
                duration_idle_cost: total_parking_cost,
            },
        };

        Ok(cdr.into_caveat(warnings))
    }
}

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

    fn from_schema(source: &schema::v221::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::v221::Dimension<'buf>> for Option<Dimension> {
    type Warning = Warning;

    fn from_schema(source: &schema::v221::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))
    }
}

impl From<ChargingPeriod> for Period {
    fn from(period: ChargingPeriod) -> Self {
        let ChargingPeriod {
            start_date_time,
            dimensions,
        } = period;
        let mut consumed = Consumed {
            current_max: None,
            current_min: None,
            duration_charging: None,
            duration_idle: None,
            energy: None,
            power_max: None,
            power_min: None,
        };

        for dimension in dimensions {
            let Dimension {
                dimension_type,
                volume,
            } = dimension;

            match dimension_type {
                DimensionType::MinCurrent => {
                    consumed.current_min = Some(Ampere::from_decimal(volume));
                }
                DimensionType::MaxCurrent => {
                    consumed.current_max = Some(Ampere::from_decimal(volume));
                }
                DimensionType::MaxPower => {
                    consumed.power_max = Some(Kw::from_decimal(volume));
                }
                DimensionType::MinPower => {
                    consumed.power_min = Some(Kw::from_decimal(volume));
                }
                DimensionType::Energy => {
                    consumed.energy = Some(Kwh::from_decimal(volume));
                }
                DimensionType::Time => {
                    consumed.duration_charging = Some(volume.to_duration());
                }
                DimensionType::ParkingTime => {
                    consumed.duration_idle = Some(volume.to_duration());
                }
                DimensionType::Current
                | DimensionType::EnergyExport
                | DimensionType::EnergyImport
                | DimensionType::Power
                | DimensionType::ReservationTime
                | DimensionType::StateOfCharge => {
                    // The pricer does not use these dimension types.
                }
            }
        }

        Period {
            start_date_time,
            consumed,
        }
    }
}