Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timezone detection and refactor Pricer #69

Merged
merged 3 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading