ocpi-tariffs 0.19.1

OCPI tariff calculations
Documentation
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
use chrono_tz::Tz;
use serde::Deserialize;
use tracing::{debug, instrument, trace};

use crate::{json, price, Ampere, DateTime, HoursDecimal, Kw, Kwh, ParseError};

use super::v221::{
    self,
    cdr::{ChargingPeriod, Dimension, DimensionType},
};

/// Describes the properties of a single charging period.
#[derive(Debug)]
pub struct ChargePeriod {
    /// Holds properties that are valid for the entirety of this period.
    pub period_data: PeriodData,
    /// Holds properties that are valid at start instant of this period.
    pub start_instant: InstantData,
    /// Holds properties that are valid at the end instant of this period.
    pub end_instant: InstantData,
}

impl ChargePeriod {
    /// Construct a new `ChargePeriod` with zeroed values. Should be the first period in the
    /// session.
    fn new(
        local_timezone: Tz,
        period: &ChargingPeriod,
        end_date_time: DateTime,
    ) -> Result<Self, price::Error> {
        let charge_state = PeriodData::new(period)?;
        let start_instant = InstantData::zero(period.start_date_time, local_timezone);
        let end_instant = start_instant.next(&charge_state, end_date_time);

        Ok(Self {
            period_data: charge_state,
            start_instant,
            end_instant,
        })
    }

    /// Construct a period with the properties of `period` that ends on `end_date_time` which succeeds `self`.
    fn next(&self, period: &ChargingPeriod, end_date_time: DateTime) -> Result<Self, price::Error> {
        let charge_state = PeriodData::new(period)?;
        let start_instant = self.end_instant.clone();
        let end_instant = start_instant.next(&charge_state, end_date_time);

        Ok(Self {
            period_data: charge_state,
            start_instant,
            end_instant,
        })
    }
}

/// This describes the properties in the charge session that a valid during a certain period. For
/// example the `duration` field is the charge duration during a certain charging period.
#[derive(Debug)]
pub struct PeriodData {
    pub max_current: Option<Ampere>,
    pub min_current: Option<Ampere>,
    pub max_power: Option<Kw>,
    pub min_power: Option<Kw>,
    pub charging_duration: Option<Duration>,
    pub parking_duration: Option<Duration>,
    pub reservation_duration: Option<Duration>,
    pub energy: Option<Kwh>,
}

/// This describes the properties in the charge session that are instantaneous. For example
/// the `total_energy` is the total amount of energy in the charge session at a certain instant.
#[derive(Clone, Debug)]
pub struct InstantData {
    pub local_timezone: Tz,
    pub date_time: DateTime,
    pub total_charging_duration: Duration,
    pub total_duration: Duration,
    pub total_energy: Kwh,
}

impl InstantData {
    fn zero(date_time: DateTime, local_timezone: Tz) -> Self {
        Self {
            date_time,
            local_timezone,
            total_charging_duration: Duration::zero(),
            total_duration: Duration::zero(),
            total_energy: Kwh::zero(),
        }
    }

    fn next(&self, state: &PeriodData, date_time: DateTime) -> Self {
        let mut next = self.clone();

        let duration = date_time.signed_duration_since(*next.date_time);

        next.total_duration = next
            .total_duration
            .checked_add(&duration)
            .unwrap_or(Duration::MAX);

        next.date_time = date_time;

        if let Some(duration) = state.charging_duration {
            next.total_charging_duration = next
                .total_charging_duration
                .checked_add(&duration)
                .unwrap_or(Duration::MAX);
        }

        if let Some(energy) = state.energy {
            next.total_energy = next.total_energy.saturating_add(energy);
        }

        next
    }

    pub fn local_time(&self) -> NaiveTime {
        self.date_time.with_timezone(&self.local_timezone).time()
    }

    pub fn local_date(&self) -> NaiveDate {
        self.date_time
            .with_timezone(&self.local_timezone)
            .date_naive()
    }

    pub fn local_weekday(&self) -> Weekday {
        self.date_time.with_timezone(&self.local_timezone).weekday()
    }
}

impl PeriodData {
    fn new(period: &ChargingPeriod) -> Result<Self, price::Error> {
        fn convert_volume<'de, T>(
            dimension_name: &'static str,
            json: Option<&'de json::RawValue>,
        ) -> Result<T, price::Error>
        where
            T: Deserialize<'de>,
        {
            let Some(json) = json else {
                return Err(price::Error::DimensionShouldHaveVolume { dimension_name });
            };

            Ok(serde_json::from_str(json.get()).map_err(ParseError::from_cdr_serde_err)?)
        }

        let mut inst = Self {
            parking_duration: None,
            reservation_duration: None,
            max_current: None,
            min_current: None,
            max_power: None,
            min_power: None,
            charging_duration: None,
            energy: None,
        };

        for dimension in &period.dimensions {
            let Dimension {
                dimension_type: kind,
                volume,
            } = dimension;

            let dimension_type: DimensionType = match serde_json::from_str(kind.get()) {
                Ok(v) => v,
                Err(err) => {
                    // if the dimension type is unknown we log it as an error and continue on with the pricing.
                    debug!("{err}");
                    continue;
                }
            };

            match dimension_type {
                DimensionType::MinCurrent => {
                    inst.min_current = convert_volume("min_current", volume.as_deref())?;
                }
                DimensionType::MaxCurrent => {
                    inst.max_current = convert_volume("max_current", volume.as_deref())?;
                }
                DimensionType::MaxPower => {
                    inst.max_power = convert_volume("max_power", volume.as_deref())?;
                }
                DimensionType::MinPower => {
                    inst.min_power = convert_volume("min_power", volume.as_deref())?;
                }
                DimensionType::Energy => inst.energy = convert_volume("energy", volume.as_deref())?,
                DimensionType::Time => {
                    inst.charging_duration = Some(
                        convert_volume::<HoursDecimal>("charging_duration", volume.as_deref())?
                            .into(),
                    );
                }
                DimensionType::ParkingTime => {
                    inst.parking_duration = Some(
                        convert_volume::<HoursDecimal>("parking_duration", volume.as_deref())?
                            .into(),
                    );
                }
                DimensionType::ReservationTime => {
                    inst.reservation_duration =
                        convert_volume("reservation_duration", volume.as_deref())?;
                }
            }
        }

        Ok(inst)
    }
}

#[instrument(skip_all)]
pub(crate) fn extract_periods(
    cdr: &v221::Cdr<'_>,
    local_timezone: Tz,
) -> Result<Vec<ChargePeriod>, price::Error> {
    let mut periods: Vec<ChargePeriod> = Vec::new();

    for (index, period) in cdr.charging_periods.iter().enumerate() {
        trace!(index, "processing\n{period:#?}");

        let next_index = index + 1;
        let end_date_time = if let Some(next_period) = cdr.charging_periods.get(next_index) {
            next_period.start_date_time
        } else {
            trace!(next_index, "No period found");
            cdr.end_date_time
        };

        let next = if let Some(last) = periods.last() {
            let period = last.next(period, end_date_time)?;
            trace!("Adding new period based on the last added\n{period:#?}\n{last:#?}");
            period
        } else {
            let period = ChargePeriod::new(local_timezone, period, end_date_time)?;
            trace!("Adding new period\n{period:#?}");
            period
        };

        periods.push(next);
    }

    Ok(periods)
}

#[cfg(test)]
mod test_extract_periods {
    use crate::{
        de::obj_from_json_str,
        price::{v211, v221},
        test::{self, datetime_from_str},
    };

    use super::{extract_periods, ChargePeriod};

    #[test]
    fn should_extract_periods() {
        const JSON: &str =
            include_str!("../../test_data/v211/real_world/dates_without_timezone/cdr.json");
        const TIMEZONE: chrono_tz::Tz = chrono_tz::Tz::Europe__Amsterdam;

        test::setup();

        let (cdr, _) = obj_from_json_str::<v211::Cdr<'_>>(JSON).unwrap();
        let cdr: v221::Cdr<'_> = cdr.into();
        let periods = extract_periods(&cdr, TIMEZONE).unwrap();

        let [period_0, period_1] = periods.try_into().unwrap();

        assert_eq!(period_0.start_instant.local_timezone, TIMEZONE);
        assert_eq!(
            period_0.end_instant.local_timezone,
            period_1.start_instant.local_timezone
        );
        assert_eq!(
            period_0.end_instant.date_time,
            period_1.start_instant.date_time
        );

        {
            let ChargePeriod { start_instant, .. } = period_0;
            assert_eq!(
                start_instant.date_time,
                datetime_from_str("2025-04-07T15:48:21")
            );
        }

        {
            let ChargePeriod { end_instant, .. } = period_1;
            assert_eq!(
                end_instant.date_time,
                datetime_from_str("2025-04-08T6:23:39")
            );
        }
    }
}