Skip to content

Commit

Permalink
Add timezone detection and refactor Pricer (#69)
Browse files Browse the repository at this point in the history
* add timezone detection and refactor pricer

* review comments
  • Loading branch information
remkop22 authored Jun 3, 2024
1 parent cfdb387 commit ff429aa
Show file tree
Hide file tree
Showing 19 changed files with 317 additions and 122 deletions.
8 changes: 4 additions & 4 deletions cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ impl TariffArgs {
None
};

let pricer = if let Some(tariff) = tariff.clone() {
Pricer::with_tariffs(&cdr, &[tariff], self.timezone)
} else {
Pricer::new(&cdr, self.timezone)
let mut pricer = Pricer::new(&cdr);

if let Some(tariff) = &tariff {
pricer = pricer.with_tariffs([tariff]);
};

let report = pricer.build_report().map_err(Error::Internal)?;
Expand Down
13 changes: 13 additions & 0 deletions ocpi-tariffs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,20 @@ pub enum Error {
///
/// A valid tariff must have a start date time before the start of the session and a end date
/// time after the start of the session.
///
/// If the session does not contain any tariffs consider providing a list of tariffs using
/// [`pricer::Pricer::with_tariffs`].
NoValidTariff,
/// A numeric overflow occurred during tariff calculation.
NumericOverflow,
/// The CDR location did not contain a time-zone. If time zone detection was enabled and this
/// error still occurs it means that the country specified in the CDR has multiple time-zones.
/// Consider explicitly using a time-zone using [`pricer::Pricer::with_time_zone`].
TimeZoneMissing,
/// The CDR location did not contain a valid time-zone. Consider enabling time-zone detection
/// as a fall back using [`pricer::Pricer::detect_time_zone`] or explicitly providing a time
/// zone using [`pricer::Pricer::with_time_zone`].
TimeZoneInvalid,
}

impl From<rust_decimal::Error> for Error {
Expand All @@ -48,6 +59,8 @@ impl fmt::Display for Error {
let display = match self {
Self::NoValidTariff => "No valid tariff has been found in the list of provided tariffs",
Self::NumericOverflow => "A numeric overflow occurred during tariff calculation",
Self::TimeZoneMissing => "No time zone could be found in the session information",
Self::TimeZoneInvalid => "The time zone in the CDR is invalid",
};

f.write_str(display)
Expand Down
22 changes: 22 additions & 0 deletions ocpi-tariffs/src/ocpi/v211/cdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub struct Cdr {
#[serde(deserialize_with = "null_default", default)]
pub tariffs: Vec<OcpiTariff>,

/// Describes the location that the charge-session took place at.
pub location: OcpiLocation,

/// 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<OcpiChargingPeriod>,
Expand All @@ -47,6 +50,15 @@ pub struct Cdr {
pub last_updated: DateTime,
}

/// Describes the location that the charge-session took place at.
#[derive(Clone, Deserialize, Serialize)]
pub struct OcpiLocation {
/// ISO 3166-1 alpha-3 code for the country of this location.
pub country: String,
/// One of IANA tzdata's TZ-values representing the time zone of the location. Examples: "Europe/Oslo", "Europe/Zurich"
pub time_zone: Option<String>,
}

/// The volume that has been consumed for a specific dimension during a charging period.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "type", content = "volume")]
Expand Down Expand Up @@ -96,6 +108,7 @@ impl From<Cdr> for v221::cdr::Cdr {
end_date_time: cdr.stop_date_time,
start_date_time: cdr.start_date_time,
last_updated: cdr.last_updated,
cdr_location: cdr.location.into(),
charging_periods: cdr
.charging_periods
.into_iter()
Expand All @@ -118,6 +131,15 @@ impl From<Cdr> for v221::cdr::Cdr {
}
}

impl From<OcpiLocation> for v221::cdr::OcpiCdrLocation {
fn from(value: OcpiLocation) -> Self {
Self {
country: value.country,
time_zone: value.time_zone,
}
}
}

impl From<OcpiCdrDimension> for Option<v221::cdr::OcpiCdrDimension> {
fn from(dimension: OcpiCdrDimension) -> Self {
use v221::cdr::OcpiCdrDimension as OcpiCdrDimension221;
Expand Down
4 changes: 4 additions & 0 deletions ocpi-tariffs/src/ocpi/v211/tariff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ use crate::ocpi::v221;
/// The Tariff object describes a tariff and its properties
#[derive(Clone, Deserialize, Serialize)]
pub struct OcpiTariff {
/// The OCPI id of this tariff.
pub id: String,

/// Currency of this tariff, ISO 4217 Code
pub currency: String,

Expand Down Expand Up @@ -112,6 +115,7 @@ pub struct OcpiTariffRestriction {
impl From<OcpiTariff> for v221::tariff::OcpiTariff {
fn from(tariff: OcpiTariff) -> Self {
Self {
id: tariff.id,
currency: tariff.currency,
min_price: None,
max_price: None,
Expand Down
19 changes: 19 additions & 0 deletions ocpi-tariffs/src/ocpi/v221/cdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub struct Cdr {
#[serde(deserialize_with = "null_default", default)]
pub tariffs: Vec<OcpiTariff>,

/// Describes the location that the charge-session took place at.
pub cdr_location: OcpiCdrLocation,

/// 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<OcpiChargingPeriod>,
Expand Down Expand Up @@ -61,6 +64,22 @@ pub struct Cdr {
pub last_updated: DateTime,
}

/// Describes the location that the charge-session took place at.
#[derive(Clone, Deserialize, Serialize)]
pub struct OcpiCdrLocation {
/// ISO 3166-1 alpha-3 code for the country of this location.
pub country: String,
/// Optional time-zone information.
///
/// NOTE: according to OCPI 2.2.1 the CDR location does not contain this field. It is added
/// here to allow to conversion from OCPI 2.1.1 locations without losing time-zone information.
///
/// It will not be included when serializing the location in order to stay compliant to OCPI
/// 2.2.1.
#[serde(skip_serializing)]
pub time_zone: Option<String>,
}

/// The volume that has been consumed for a specific dimension during a charging period.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "type", content = "volume")]
Expand Down
3 changes: 3 additions & 0 deletions ocpi-tariffs/src/ocpi/v221/tariff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ use crate::null_default;
/// The Tariff object describes a tariff and its properties
#[derive(Clone, Deserialize, Serialize)]
pub struct OcpiTariff {
/// The OCPI id of this tariff.
pub id: String,

/// Currency of this tariff, ISO 4217 Code
pub currency: String,

Expand Down
117 changes: 88 additions & 29 deletions ocpi-tariffs/src/pricer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ use crate::{
tariff::{CompatibilityVat, OcpiTariff},
},
session::{ChargePeriod, ChargeSession, PeriodData},
tariff::{PriceComponent, PriceComponents, Tariffs},
tariff::{PriceComponent, PriceComponents, Tariff},
types::{
electricity::Kwh,
money::{Money, Price},
number::Number,
time::HoursDecimal,
time::{try_detect_time_zone, DateTime as OcpiDateTime, HoursDecimal},
},
Error, Result,
};
Expand All @@ -24,46 +24,89 @@ use serde::Serialize;
///
/// Either specify a `Cdr` containing a list of tariffs.
/// ```ignore
/// let pricer = Pricer::new(cdr, Tz::Europe__Amsterdam);
/// let report = pricer.build_report();
/// let report = Pricer::new(cdr)
/// .with_time_zone(Tz::Europe__Amsterdam)
/// .build_report()
/// .unwrap();
/// ```
///
/// Or provide both the `Cdr` and a slice of `OcpiTariff`'s.
/// ```ignore
/// let pricer = Pricer::with_tariffs(cdr, tariffs, Tz::Europe__Amsterdam);
/// let report = pricer.build_report();
/// let pricer = Pricer::new(cdr)
/// .with_tariffs(tariffs)
/// .detect_time_zone(true)
/// .build_report()
/// .unwrap();
/// ```
pub struct Pricer {
session: ChargeSession,
tariffs: Tariffs,
pub struct Pricer<'a> {
cdr: &'a Cdr,
tariffs: Option<Vec<&'a OcpiTariff>>,
time_zone: Option<Tz>,
detect_time_zone: bool,
}

impl Pricer {
/// Instantiate the pricer with a `Cdr` that contains at least on tariff.
/// Provide the `local_timezone` of the area where this charge session was priced.
pub fn new(cdr: &Cdr, local_timezone: Tz) -> Self {
impl<'a> Pricer<'a> {
/// Create a new pricer instance using the specified [`Cdr`].
pub fn new(cdr: &'a Cdr) -> Self {
Self {
session: ChargeSession::new(cdr, local_timezone),
tariffs: Tariffs::new(&cdr.tariffs),
cdr,
time_zone: None,
detect_time_zone: false,
tariffs: None,
}
}

/// Instantiate the pricer with a `Cdr` and a slice that contains at least on tariff.
/// Provide the `local_timezone` of the area where this charge session was priced.
pub fn with_tariffs(cdr: &Cdr, tariffs: &[OcpiTariff], local_timezone: Tz) -> Self {
Self {
session: ChargeSession::new(cdr, local_timezone),
tariffs: Tariffs::new(tariffs),
}
/// Use a list of [`OcpiTariff`]'s for pricing instead of the tariffs found in the [`Cdr`].
pub fn with_tariffs(mut self, tariffs: impl IntoIterator<Item = &'a OcpiTariff>) -> Self {
self.tariffs = Some(tariffs.into_iter().collect());

self
}

/// Directly specify a time zone to use for the calculation. This overrides any time zones in
/// the session or any detected time zones if [`Self::detect_time_zone`] is set to true.
pub fn with_time_zone(mut self, time_zone: Tz) -> Self {
self.time_zone = Some(time_zone);

self
}

/// Attempt to apply the first found valid tariff the charge session and build a report
/// Try to detect a time zone from the country code inside the [`Cdr`] if the actual time zone
/// is missing. The detection will only succeed if the country has just one time-zone,
/// nonetheless there are edge cases where the detection will be incorrect. Only use this
/// feature as a fallback when a certain degree of inaccuracy is allowed.
pub fn detect_time_zone(mut self, detect: bool) -> Self {
self.detect_time_zone = detect;

self
}

/// Attempt to apply the first applicable tariff to the charge session and build a report
/// containing the results.
pub fn build_report(&self) -> Result<Report> {
let (tariff_index, tariff) = self
.tariffs
.active_tariff(self.session.start_date_time)
.ok_or(Error::NoValidTariff)?;
pub fn build_report(self) -> Result<Report> {
let cdr_tz = self.cdr.cdr_location.time_zone.as_ref();

let time_zone = if let Some(tz) = self.time_zone {
tz
} else if let Some(tz) = cdr_tz {
tz.parse().map_err(|_| Error::TimeZoneInvalid)?
} else if self.detect_time_zone {
try_detect_time_zone(&self.cdr.cdr_location.country).ok_or(Error::TimeZoneMissing)?
} else {
return Err(Error::TimeZoneMissing);
};

let cdr = ChargeSession::new(self.cdr, time_zone);

let active = if let Some(tariffs) = self.tariffs {
Self::first_active_tariff(tariffs, cdr.start_date_time)
} else if !self.cdr.tariffs.is_empty() {
Self::first_active_tariff(&self.cdr.tariffs, cdr.start_date_time)
} else {
None
};

let (tariff_index, tariff) = active.ok_or(Error::NoValidTariff)?;

let mut periods = Vec::new();
let mut step_size = StepSize::new();
Expand All @@ -72,7 +115,7 @@ impl Pricer {
let mut total_charging_time = HoursDecimal::zero();
let mut total_parking_time = HoursDecimal::zero();

for (index, period) in self.session.periods.iter().enumerate() {
for (index, period) in cdr.periods.iter().enumerate() {
let components = tariff.active_components(period);

step_size.update(index, &components, period);
Expand Down Expand Up @@ -173,6 +216,8 @@ impl Pricer {
let report = Report {
periods,
tariff_index,
tariff_id: tariff.id,
time_zone: time_zone.to_string(),
total_cost,
total_time_cost,
total_charging_time,
Expand All @@ -190,6 +235,16 @@ impl Pricer {

Ok(report)
}

fn first_active_tariff<'b>(
iter: impl IntoIterator<Item = &'b OcpiTariff>,
start_date_time: OcpiDateTime,
) -> Option<(usize, Tariff)> {
iter.into_iter()
.map(Tariff::new)
.enumerate()
.find(|(_, t)| t.is_active(start_date_time))
}
}

struct StepSize {
Expand Down Expand Up @@ -334,6 +389,10 @@ pub struct Report {
pub periods: Vec<PeriodReport>,
/// Index of the tariff that was found to be active.
pub tariff_index: usize,
/// Id of the tariff that was found to be active.
pub tariff_id: String,
/// Time zone that was either specified or detected.
pub time_zone: String,
/// Total sum of all the costs of this transaction in the specified currency.
pub total_cost: Option<Price>,
/// Total sum of all the cost related to duration of charging during this transaction, in the specified currency.
Expand Down
19 changes: 3 additions & 16 deletions ocpi-tariffs/src/tariff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,15 @@ use crate::restriction::{collect_restrictions, Restriction};
use crate::session::ChargePeriod;
use crate::types::{money::Money, time::DateTime};

pub struct Tariffs(Vec<Tariff>);

impl Tariffs {
pub fn new(tariffs: &[OcpiTariff]) -> Self {
Self(tariffs.iter().map(Tariff::new).collect())
}

pub fn active_tariff(&self, start_time: DateTime) -> Option<(usize, &Tariff)> {
self.0
.iter()
.position(|t| t.is_active(start_time))
.map(|i| (i, &self.0[i]))
}
}

pub struct Tariff {
pub id: String,
elements: Vec<TariffElement>,
start_date_time: Option<DateTime>,
end_date_time: Option<DateTime>,
}

impl Tariff {
fn new(tariff: &OcpiTariff) -> Self {
pub fn new(tariff: &OcpiTariff) -> Self {
let elements = tariff
.elements
.iter()
Expand All @@ -39,6 +25,7 @@ impl Tariff {
.collect();

Self {
id: tariff.id.clone(),
start_date_time: tariff.start_date_time,
end_date_time: tariff.end_date_time,
elements,
Expand Down
Loading

0 comments on commit ff429aa

Please sign in to comment.