ocpi-tariffs 0.46.1

OCPI tariff calculations
Documentation
//! The OCPI spec represents some durations as fractional hours, where this crate represents all
//! durations using [`TimeDelta`]. The [`ToDuration`] and [`ToHoursDecimal`] traits can be used to
//! convert a [`TimeDelta`] into a [`Decimal`] and vice versa.

#[cfg(test)]
pub(crate) mod test;

#[cfg(test)]
mod test_hour_decimal;

use std::fmt;

use chrono::TimeDelta;
use num_traits::ToPrimitive as _;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;

use crate::{
    json,
    number::{self, int_error_kind_as_str, FromDecimal as _, RoundDecimal},
    warning::{self, IntoCaveat as _},
    Cost, Money, SaturatingAdd, SaturatingSub, Verdict,
};

pub(crate) const SECS_IN_MIN: i64 = 60;
pub(crate) const MINS_IN_HOUR: i64 = 60;
pub(crate) const MILLIS_IN_SEC: i64 = 1000;
const NANOS_IN_HOUR: Decimal = dec!(36e11);
const SECONDS_IN_HOUR: Decimal = dec!(3600);

/// The warnings possible when parsing or linting a duration.
#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
pub enum Warning {
    /// Unable to parse the duration.
    Invalid(&'static str),

    /// The JSON value given is not an int.
    InvalidType { type_found: json::ValueKind },

    /// A numeric overflow occurred while creating a duration.
    Overflow,
}

impl Warning {
    fn invalid_type(elem: &json::Element<'_>) -> Self {
        Self::InvalidType {
            type_found: elem.value().kind(),
        }
    }
}

impl fmt::Display for Warning {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Invalid(err) => write!(f, "Unable to parse the duration: {err}"),
            Self::InvalidType { type_found } => {
                write!(f, "The value should be an int but is `{type_found}`")
            }
            Self::Overflow => f.write_str("A numeric overflow occurred while creating a duration"),
        }
    }
}

impl crate::Warning for Warning {
    fn id(&self) -> warning::Id {
        match self {
            Self::Invalid(_) => warning::Id::from_static("invalid"),
            Self::InvalidType { .. } => warning::Id::from_static("invalid_type"),
            Self::Overflow => warning::Id::from_static("overflow"),
        }
    }
}

impl From<rust_decimal::Error> for Warning {
    fn from(_: rust_decimal::Error) -> Self {
        Self::Overflow
    }
}

/// Convert a `TimeDelta` into a `Decimal` based amount of hours.
pub trait ToHoursDecimal {
    /// Return a `Decimal` based amount of hours.
    fn to_hours_dec(&self) -> Decimal;
    /// Return a `Decimal` based amount of hours, in the precision specified
    /// by OCPI.
    ///
    /// Note this should only be used for output, for intermediate results use
    /// `to_hours_dec`, to avoid rounding.
    fn to_hours_dec_in_ocpi_precision(&self) -> Decimal {
        self.to_hours_dec().round_to_ocpi_scale()
    }
}

impl ToHoursDecimal for TimeDelta {
    fn to_hours_dec(&self) -> Decimal {
        let num_sec = Decimal::from(self.num_seconds());
        let num_nano = Decimal::from(self.subsec_nanos());
        let sec_part = num_sec.checked_div(SECONDS_IN_HOUR).unwrap_or(Decimal::MAX);
        let nano_part = num_nano.checked_div(NANOS_IN_HOUR).unwrap_or(Decimal::MAX);
        sec_part.checked_add(nano_part).unwrap_or(Decimal::MAX)
    }
}

/// Convert a `Decimal` amount of hours to a `TimeDelta`.
pub trait ToDuration {
    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
    fn to_duration(&self) -> TimeDelta;
    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
    ///
    /// Ceil the number of nanoseconds up to avoid exceeding `max_power`.
    fn to_duration_ceil_nanos(&self) -> TimeDelta;
}

impl ToDuration for Decimal {
    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
    ///
    /// Round to the maximal precision of `TimeDelta` which is nanoseconds.
    fn to_duration(&self) -> TimeDelta {
        let nanos = self
            .saturating_mul(NANOS_IN_HOUR)
            .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
            .to_i64()
            .unwrap_or(i64::MAX);
        TimeDelta::nanoseconds(nanos)
    }

    /// Convert a `Decimal` amount of hours to a `TimeDelta`.
    ///
    /// Use the maximal precision of `TimeDelta` which is nanoseconds,
    /// using ceil to avoid exceeding `max_power`.
    fn to_duration_ceil_nanos(&self) -> TimeDelta {
        let nanos = self
            .saturating_mul(NANOS_IN_HOUR)
            .ceil()
            .to_i64()
            .unwrap_or(i64::MAX);
        TimeDelta::nanoseconds(nanos)
    }
}

/// A `TimeDelta` can't be parsed from JSON directly, you must first decide which unit of time to
/// parse it as. The `Seconds` type is used to parse the JSON Element as an integer amount of seconds.
pub(crate) struct Seconds(TimeDelta);

impl number::IsZero for Seconds {
    fn is_zero(&self) -> bool {
        self.0.is_zero()
    }
}

/// Once the `TimeDelta` has been parsed as seconds you can extract it from the newtype.
impl From<Seconds> for TimeDelta {
    fn from(value: Seconds) -> Self {
        value.0
    }
}

impl fmt::Debug for Seconds {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_tuple("Seconds")
            .field(&self.0.num_seconds())
            .finish()
    }
}

impl fmt::Display for Seconds {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0.num_seconds())
    }
}

/// Parse a seconds `TimeDelta` from JSON.
///
/// Used to parse the `min_duration` and `max_duration` fields of the tariff Restriction.
///
/// * See: [OCPI spec 2.2.1: Tariff Restriction](<https://github.com/ocpi/ocpi/blob/release-2.2.1-bugfixes/mod_tariffs.asciidoc#146-tariffrestrictions-class>)
/// * See: [OCPI spec 2.1.1: Tariff Restriction](<https://github.com/ocpi/ocpi/blob/release-2.1.1-bugfixes/mod_tariffs.md#45-tariffrestrictions-class>)
impl json::FromJson<'_> for Seconds {
    type Warning = Warning;

    fn from_json(elem: &'_ json::Element<'_>) -> Verdict<Self, Self::Warning> {
        let warnings = warning::Set::new();
        let Some(s) = elem.as_number_str() else {
            return warnings.bail(Warning::invalid_type(elem), elem);
        };

        // We only support positive durations in an OCPI object.
        let seconds = match s.parse::<u64>() {
            Ok(n) => n,
            Err(err) => {
                return warnings.bail(Warning::Invalid(int_error_kind_as_str(*err.kind())), elem);
            }
        };

        // Then we convert the positive duration to an i64 as that is how `chrono::TimeDelta`
        // represents seconds.
        let Ok(seconds) = i64::try_from(seconds) else {
            return warnings.bail(
                Warning::Invalid("The duration value is larger than an i64 can represent."),
                elem,
            );
        };
        let dt = TimeDelta::seconds(seconds);

        Ok(Seconds(dt).into_caveat(warnings))
    }
}

/// A duration of time has a cost.
impl Cost for TimeDelta {
    fn cost(&self, money: Money) -> Money {
        let cost = self.to_hours_dec().saturating_mul(Decimal::from(money));
        Money::from_decimal(cost)
    }
}

impl SaturatingAdd for TimeDelta {
    fn saturating_add(self, other: TimeDelta) -> TimeDelta {
        self.checked_add(&other).unwrap_or(TimeDelta::MAX)
    }
}

impl SaturatingSub for TimeDelta {
    fn saturating_sub(self, other: TimeDelta) -> TimeDelta {
        self.checked_sub(&other).unwrap_or_else(TimeDelta::zero)
    }
}

/// A debug helper trait to display durations as `HH:MM:SS`.
#[allow(dead_code, reason = "used during debug sessions")]
pub(crate) trait AsHms {
    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
    fn as_hms(&self) -> Hms;
}

impl AsHms for TimeDelta {
    fn as_hms(&self) -> Hms {
        Hms(*self)
    }
}

impl AsHms for Decimal {
    /// Return a `Hms` formatter, that formats a `TimeDelta` into a `String` in `HH::MM::SS` format.
    fn as_hms(&self) -> Hms {
        Hms(self.to_duration())
    }
}

/// A utility for deserializing and displaying durations in `HH::MM::SS` format.
#[derive(Copy, Clone)]
pub struct Hms(pub TimeDelta);

/// The Debug and Display impls are the same for `Hms` as I never want to see the `TimeDelta` representation.
impl fmt::Debug for Hms {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(self, f)
    }
}

impl fmt::Display for Hms {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let duration = self.0;
        let seconds = duration.num_seconds();

        // If the duration is negative write a single minus sign.
        if seconds.is_negative() {
            f.write_str("-")?;
        }

        // Avoid minus signs in the output.
        let seconds_total = seconds.abs();

        let seconds = seconds_total % SECS_IN_MIN;
        let minutes = (seconds_total / SECS_IN_MIN) % MINS_IN_HOUR;
        let hours = seconds_total / (SECS_IN_MIN * MINS_IN_HOUR);

        write!(f, "{hours:0>2}:{minutes:0>2}:{seconds:0>2}")
    }
}

#[cfg(test)]
mod test_hms {
    use chrono::TimeDelta;

    use super::Hms;

    #[test]
    fn should_display_seconds() {
        assert_eq!(Hms(TimeDelta::seconds(0)).to_string(), "00:00:00");
        assert_eq!(Hms(TimeDelta::seconds(59)).to_string(), "00:00:59");
    }

    #[test]
    fn should_display_minutes() {
        assert_eq!(Hms(TimeDelta::seconds(60)).to_string(), "00:01:00");
        assert_eq!(Hms(TimeDelta::seconds(3600)).to_string(), "01:00:00");
    }

    #[test]
    fn should_display_hours() {
        assert_eq!(Hms(TimeDelta::minutes(60)).to_string(), "01:00:00");
        assert_eq!(Hms(TimeDelta::minutes(3600)).to_string(), "60:00:00");
    }

    #[test]
    fn should_display_hours_mins_secs() {
        assert_eq!(Hms(TimeDelta::seconds(87030)).to_string(), "24:10:30");
    }
}

#[cfg(test)]
mod test_to_hours_decimal {
    use chrono::TimeDelta;
    use rust_decimal_macros::dec;

    use crate::ToHoursDecimal as _;

    #[test]
    fn to_hours_dec_should_be_correct() {
        let actual = TimeDelta::hours(1).to_hours_dec();
        assert_eq!(actual, dec!(1.0));

        let actual = TimeDelta::seconds(3960).to_hours_dec();
        assert_eq!(actual, dec!(1.1));

        let actual = TimeDelta::seconds(360).to_hours_dec();
        assert_eq!(actual, dec!(0.1));

        let actual = TimeDelta::seconds(36).to_hours_dec();
        assert_eq!(actual, dec!(0.01));

        let actual = TimeDelta::milliseconds(36).to_hours_dec();
        assert_eq!(actual, dec!(0.00001));

        let actual = TimeDelta::nanoseconds(1).to_hours_dec();
        assert_eq!(actual, dec!(2.777777777777778e-13));
    }
}

#[cfg(test)]
mod test_to_duration {
    use chrono::TimeDelta;
    use rust_decimal_macros::dec;

    use crate::ToDuration as _;

    #[test]
    fn to_duration_should_be_correct() {
        let actual = dec!(1.0).to_duration();
        assert_eq!(actual, TimeDelta::hours(1));

        let actual = dec!(1.1).to_duration();
        assert_eq!(actual, TimeDelta::seconds(3960));

        let actual = dec!(0.1).to_duration();
        assert_eq!(actual, TimeDelta::seconds(360));

        let actual = dec!(1e-14).to_duration();
        assert_eq!(actual, TimeDelta::zero());

        let actual = dec!(2.777e-13).to_duration();
        assert_eq!(actual, TimeDelta::nanoseconds(1));
    }
}