#[cfg(test)]
mod test;
#[cfg(test)]
mod test_clamp_date_time_span;
#[cfg(test)]
mod test_gen_time_events;
#[cfg(test)]
mod test_generate;
#[cfg(test)]
mod test_generate_from_single_elem_tariff;
#[cfg(test)]
mod test_local_to_utc;
#[cfg(test)]
mod test_periods;
#[cfg(test)]
mod test_power_to_time;
#[cfg(test)]
mod test_popular_tariffs;
mod v2x;
use std::{
cmp::{max, min},
fmt,
ops::Range,
};
use chrono::{DateTime, Datelike as _, NaiveDateTime, NaiveTime, TimeDelta, Utc};
use rust_decimal::{prelude::ToPrimitive, Decimal};
use rust_decimal_macros::dec;
use tracing::{debug, instrument, warn};
use crate::{
country, currency,
duration::{AsHms, ToHoursDecimal},
energy::{Ampere, Kw, Kwh},
from_warning_all,
json::FromJson as _,
number::{FromDecimal as _, RoundDecimal},
price, tariff,
warning::{self, GatherWarnings as _, IntoCaveat, WithElement as _},
Price, SaturatingAdd, Version, Versioned,
};
const MIN_CS_DURATION_SECS: i64 = 120;
type DateTimeSpan = Range<DateTime<Utc>>;
type Verdict<T> = crate::Verdict<T, Warning>;
pub type Caveat<T> = warning::Caveat<T, Warning>;
macro_rules! some_dec_or_bail {
($elem:expr, $opt:expr, $warnings:expr, $msg:literal) => {
match $opt {
Some(v) => v,
None => {
return $warnings.bail(Warning::Decimal($msg), $elem.as_element());
}
}
};
}
#[derive(Debug)]
pub struct Report {
pub tariff_id: String,
pub tariff_currency_code: currency::Code,
pub partial_cdr: PartialCdr,
}
#[derive(Debug)]
pub struct PartialCdr {
pub currency_code: currency::Code,
pub party_id: Option<CpoId>,
pub start_date_time: DateTime<Utc>,
pub end_date_time: DateTime<Utc>,
pub total_energy: Option<Kwh>,
pub total_charging_duration: Option<TimeDelta>,
pub total_parking_duration: Option<TimeDelta>,
pub total_cost: Option<Price>,
pub total_energy_cost: Option<Price>,
pub total_fixed_cost: Option<Price>,
pub total_parking_duration_cost: Option<Price>,
pub total_charging_duration_cost: Option<Price>,
pub charging_periods: Vec<ChargingPeriod>,
}
#[derive(Clone, Debug)]
pub struct CpoId {
pub country_code: country::Code,
pub id: String,
}
impl<'buf> From<tariff::CpoId<'buf>> for CpoId {
fn from(value: tariff::CpoId<'buf>) -> Self {
let tariff::CpoId { country_code, id } = value;
CpoId {
country_code,
id: id.to_string(),
}
}
}
impl fmt::Display for CpoId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.country_code.into_alpha_2_str(), self.id)
}
}
#[derive(Debug)]
pub struct ChargingPeriod {
pub start_date_time: DateTime<Utc>,
pub dimensions: Vec<Dimension>,
pub tariff_id: Option<String>,
}
#[derive(Debug)]
pub struct Dimension {
pub dimension_type: DimensionType,
pub volume: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DimensionType {
Energy,
MaxCurrent,
MinCurrent,
MaxPower,
MinPower,
ParkingTime,
ReservationTime,
Time,
}
#[derive(Clone)]
pub struct Config {
pub timezone: chrono_tz::Tz,
pub end_date_time: DateTime<Utc>,
pub max_current_supply_amp: Decimal,
pub requested_kwh: Decimal,
pub max_power_supply_kw: Decimal,
pub start_date_time: DateTime<Utc>,
}
pub fn cdr_from_tariff(tariff_elem: &tariff::Versioned<'_>, config: &Config) -> Verdict<Report> {
let mut warnings = warning::Set::new();
let (metrics, timezone) = metrics(tariff_elem, config)?.gather_warnings_into(&mut warnings);
let tariff = match tariff_elem.version() {
Version::V211 => {
let tariff = tariff::v211::Tariff::from_json(tariff_elem.as_element())?
.gather_warnings_into(&mut warnings);
tariff::v221::Tariff::from(tariff)
}
Version::V221 => tariff::v221::Tariff::from_json(tariff_elem.as_element())?
.gather_warnings_into(&mut warnings),
};
if !is_tariff_active(&metrics.start_date_time, &tariff) {
warnings.insert(tariff::Warning::NotActive.into(), tariff_elem.as_element());
}
let timeline = timeline(timezone, &metrics, &tariff);
let charging_periods = charge_periods(&metrics, timeline);
let report = price::periods(metrics.end_date_time, timezone, &tariff, charging_periods)
.with_element(tariff_elem.as_element())?
.gather_warnings_into(&mut warnings);
let price::PeriodsReport {
billable: _,
periods,
totals,
total_costs,
} = report;
let charging_periods = periods
.into_iter()
.map(|period| {
let price::PeriodReport {
start_date_time,
end_date_time: _,
dimensions,
} = period;
let time = dimensions
.duration_charging
.volume
.as_ref()
.map(|dt| Dimension {
dimension_type: DimensionType::Time,
volume: ToHoursDecimal::to_hours_dec(dt),
});
let parking_time = dimensions
.duration_parking
.volume
.as_ref()
.map(|dt| Dimension {
dimension_type: DimensionType::ParkingTime,
volume: ToHoursDecimal::to_hours_dec(dt),
});
let energy = dimensions.energy.volume.as_ref().map(|kwh| Dimension {
dimension_type: DimensionType::Energy,
volume: (*kwh).into(),
});
let dimensions = vec![energy, parking_time, time]
.into_iter()
.flatten()
.collect();
ChargingPeriod {
start_date_time,
dimensions,
tariff_id: Some(tariff.id.to_string()),
}
})
.collect();
let mut total_cost = total_costs.total();
if let Some(total_cost) = total_cost.as_mut() {
if let Some(min_price) = tariff.min_price {
if *total_cost < min_price {
*total_cost = min_price;
warnings.insert(
tariff::Warning::TotalCostClampedToMin.into(),
tariff_elem.as_element(),
);
}
}
if let Some(max_price) = tariff.max_price {
if *total_cost > max_price {
*total_cost = max_price;
warnings.insert(
tariff::Warning::TotalCostClampedToMin.into(),
tariff_elem.as_element(),
);
}
}
}
let report = Report {
tariff_id: tariff.id.to_string(),
tariff_currency_code: tariff.currency,
partial_cdr: PartialCdr {
party_id: tariff.party_id.map(CpoId::from),
start_date_time: metrics.start_date_time,
end_date_time: metrics.end_date_time,
currency_code: tariff.currency,
total_energy: totals.energy.round_to_ocpi_scale(),
total_charging_duration: totals.duration_charging,
total_parking_duration: totals.duration_parking,
total_cost: total_cost.round_to_ocpi_scale(),
total_energy_cost: total_costs.energy.round_to_ocpi_scale(),
total_fixed_cost: total_costs.fixed.round_to_ocpi_scale(),
total_parking_duration_cost: total_costs.duration_parking.round_to_ocpi_scale(),
total_charging_duration_cost: total_costs.duration_charging.round_to_ocpi_scale(),
charging_periods,
},
};
Ok(report.into_caveat(warnings))
}
struct EventCollector {
session_duration: TimeDelta,
events: Vec<Event>,
}
impl EventCollector {
fn with_session_duration(session_duration: TimeDelta) -> Self {
Self {
session_duration,
events: vec![],
}
}
fn push(&mut self, duration_from_start: TimeDelta, event_kind: EventKind) {
if duration_from_start <= self.session_duration {
self.events.push(Event {
duration_from_start,
kind: event_kind,
});
}
}
fn push_with(&mut self, event_kind: EventKind) -> impl FnOnce(TimeDelta) + use<'_> {
move |dt| {
self.push(dt, event_kind);
}
}
fn into_inner(self) -> Vec<Event> {
self.events
}
}
fn timeline(
timezone: chrono_tz::Tz,
metrics: &Metrics,
tariff: &tariff::v221::Tariff<'_>,
) -> Timeline {
let Metrics {
start_date_time: cdr_start,
end_date_time: cdr_end,
duration_charging,
duration_parking,
max_power_supply,
max_current_supply,
energy_supplied: _,
} = metrics;
let mut events = {
let session_duration = duration_parking.map(|d| duration_charging.saturating_add(d));
let mut events =
EventCollector::with_session_duration(session_duration.unwrap_or(*duration_charging));
events.push(TimeDelta::seconds(0), EventKind::SessionStart);
events.push(*duration_charging, EventKind::ChargingEnd);
session_duration.map(events.push_with(EventKind::ParkingEnd {
start: *duration_charging,
}));
events
};
let mut emit_current = false;
let mut emit_power = false;
for elem in &tariff.elements {
if let Some((time_restrictions, energy_restrictions)) = elem
.restrictions
.as_ref()
.map(tariff::v221::Restrictions::restrictions_by_category)
{
generate_time_events(
&mut events,
timezone,
*cdr_start..*cdr_end,
time_restrictions,
);
let v2x::EnergyRestrictions {
min_kwh,
max_kwh,
min_current,
max_current,
min_power,
max_power,
} = energy_restrictions;
if !emit_current {
emit_current = (min_current..=max_current).contains(&Some(*max_current_supply));
}
if !emit_power {
emit_power = (min_power..=max_power).contains(&Some(*max_power_supply));
}
generate_energy_events(
&mut events,
metrics.duration_charging,
metrics.energy_supplied,
min_kwh,
max_kwh,
);
}
}
let events = events.into_inner();
Timeline {
events,
emit_current,
emit_power,
}
}
fn generate_time_events(
events: &mut EventCollector,
timezone: chrono_tz::Tz,
cdr_span: DateTimeSpan,
restrictions: v2x::TimeRestrictions,
) {
const MIDNIGHT: NaiveTime = NaiveTime::from_hms_opt(0, 0, 0)
.expect("The hour, minute and second values are correct and hardcoded");
const ONE_DAY: TimeDelta = TimeDelta::days(1);
let v2x::TimeRestrictions {
start_time,
end_time,
start_date,
end_date,
min_duration,
max_duration,
weekdays,
} = restrictions;
let cdr_duration = cdr_span.end.signed_duration_since(cdr_span.start);
min_duration
.filter(|dt| &cdr_duration < dt)
.map(events.push_with(EventKind::MinDuration));
max_duration
.filter(|dt| &cdr_duration < dt)
.map(events.push_with(EventKind::MaxDuration));
let (start_date_time, end_date_time) =
if let (Some(start_time), Some(end_time)) = (start_time, end_time) {
if end_time < start_time {
(
start_date.map(|d| d.and_time(start_time)),
end_date.map(|d| {
let (end_time, _) = end_time.overflowing_add_signed(ONE_DAY);
d.and_time(end_time)
}),
)
} else {
(
start_date.map(|d| d.and_time(start_time)),
end_date.map(|d| d.and_time(end_time)),
)
}
} else {
(
start_date.map(|d| d.and_time(start_time.unwrap_or(MIDNIGHT))),
end_date.map(|d| d.and_time(end_time.unwrap_or(MIDNIGHT))),
)
};
let event_span = clamp_date_time_span(
start_date_time.and_then(|d| local_to_utc(timezone, d)),
end_date_time.and_then(|d| local_to_utc(timezone, d)),
cdr_span,
);
if let Some(start_time) = start_time {
gen_naive_time_events(
events,
&event_span,
start_time,
&weekdays,
EventKind::StartTime,
);
}
if let Some(end_time) = end_time {
gen_naive_time_events(events, &event_span, end_time, &weekdays, EventKind::EndTime);
}
}
fn local_to_utc(timezone: chrono_tz::Tz, date_time: NaiveDateTime) -> Option<DateTime<Utc>> {
use chrono::offset::LocalResult;
let result = date_time.and_local_timezone(timezone);
let local_date_time = match result {
LocalResult::Single(d) => d,
LocalResult::Ambiguous(earliest, _latest) => earliest,
LocalResult::None => return None,
};
Some(local_date_time.to_utc())
}
fn gen_naive_time_events(
events: &mut EventCollector,
event_span: &Range<DateTime<Utc>>,
time: NaiveTime,
weekdays: &v2x::WeekdaySet,
kind: EventKind,
) {
let time_delta = time.signed_duration_since(event_span.start.time());
let cdr_duration = event_span.end.signed_duration_since(event_span.start);
let time_delta = if time_delta.num_seconds().is_negative() {
let (time_delta, _) = time.overflowing_add_signed(TimeDelta::days(1));
time_delta.signed_duration_since(event_span.start.time())
} else {
time_delta
};
if time_delta.num_seconds().is_negative() {
return;
}
let Some(remainder) = cdr_duration.checked_sub(&time_delta) else {
warn!("TimeDelta overflow");
return;
};
if remainder.num_seconds().is_positive() {
let duration_from_start = time_delta;
let Some(date) = event_span.start.checked_add_signed(duration_from_start) else {
warn!("Date out of range");
return;
};
if weekdays.contains(date.weekday()) {
events.push(time_delta, kind);
}
for day in 1..=remainder.num_days() {
let Some(duration_from_start) = time_delta.checked_add(&TimeDelta::days(day)) else {
warn!("Date out of range");
break;
};
let Some(date) = event_span.start.checked_add_signed(duration_from_start) else {
warn!("Date out of range");
break;
};
if weekdays.contains(date.weekday()) {
events.push(duration_from_start, kind);
}
}
}
}
fn generate_energy_events(
events: &mut EventCollector,
duration_charging: TimeDelta,
energy_supplied: Kwh,
min_kwh: Option<Kwh>,
max_kwh: Option<Kwh>,
) {
min_kwh
.and_then(|kwh| power_to_time(kwh, energy_supplied, duration_charging))
.map(events.push_with(EventKind::MinKwh));
max_kwh
.and_then(|kwh| power_to_time(kwh, energy_supplied, duration_charging))
.map(events.push_with(EventKind::MaxKwh));
}
fn power_to_time(power: Kwh, power_total: Kwh, duration_total: TimeDelta) -> Option<TimeDelta> {
use rust_decimal::prelude::ToPrimitive;
let power = Decimal::from(power);
let power_total = Decimal::from(power_total);
let Some(factor) = power.checked_div(power_total) else {
return Some(TimeDelta::zero());
};
if factor.is_sign_negative() || factor > dec!(1.0) {
return None;
}
let duration_from_start = factor.checked_mul(Decimal::from(duration_total.num_seconds()))?;
duration_from_start.to_i64().map(TimeDelta::seconds)
}
fn charge_periods(metrics: &Metrics, timeline: Timeline) -> Vec<price::Period> {
enum ChargingPhase {
Charging,
Parking,
}
let Metrics {
start_date_time: cdr_start,
max_power_supply,
max_current_supply,
end_date_time: _,
duration_charging: _,
duration_parking: _,
energy_supplied: _,
} = metrics;
let Timeline {
mut events,
emit_current,
emit_power,
} = timeline;
events.sort_unstable_by_key(|e| e.duration_from_start);
let mut periods = vec![];
let emit_current = emit_current.then_some(*max_current_supply);
let emit_power = emit_power.then_some(*max_power_supply);
let mut charging_phase = ChargingPhase::Charging;
for items in events.windows(2) {
let [event, event_next] = items else {
unreachable!("The window size is 2");
};
let Event {
duration_from_start,
kind,
} = event;
if let EventKind::ChargingEnd = kind {
charging_phase = ChargingPhase::Parking;
}
let Some(duration) = event_next
.duration_from_start
.checked_sub(duration_from_start)
else {
warn!("TimeDelta overflow");
break;
};
let Some(start_date_time) = cdr_start.checked_add_signed(*duration_from_start) else {
warn!("TimeDelta overflow");
break;
};
let consumed = if let ChargingPhase::Charging = charging_phase {
let Some(energy) =
Decimal::from(*max_power_supply).checked_mul(duration.to_hours_dec())
else {
warn!("Decimal overflow");
break;
};
price::Consumed {
duration_charging: Some(duration),
duration_parking: None,
energy: Some(Kwh::from_decimal(energy)),
current_max: emit_current,
current_min: emit_current,
power_max: emit_power,
power_min: emit_power,
}
} else {
price::Consumed {
duration_charging: None,
duration_parking: Some(duration),
energy: None,
current_max: None,
current_min: None,
power_max: None,
power_min: None,
}
};
let period = price::Period {
start_date_time,
consumed,
};
periods.push(period);
}
periods
}
fn clamp_date_time_span(
min_date: Option<DateTime<Utc>>,
max_date: Option<DateTime<Utc>>,
span: DateTimeSpan,
) -> DateTimeSpan {
let (min_date, max_date) = (min(min_date, max_date), max(min_date, max_date));
let start = min_date.filter(|d| &span.start < d).unwrap_or(span.start);
let end = max_date.filter(|d| &span.end > d).unwrap_or(span.end);
DateTimeSpan { start, end }
}
struct Timeline {
events: Vec<Event>,
emit_current: bool,
emit_power: bool,
}
struct Event {
duration_from_start: TimeDelta,
kind: EventKind,
}
impl fmt::Debug for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Event")
.field("duration_from_start", &self.duration_from_start.as_hms())
.field("kind", &self.kind)
.finish()
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum EventKind {
SessionStart,
ChargingEnd,
ParkingEnd {
start: TimeDelta,
},
StartTime,
EndTime,
MinDuration,
MaxDuration,
MinKwh,
MaxKwh,
}
impl fmt::Debug for EventKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SessionStart => write!(f, "SessionStart"),
Self::ChargingEnd => write!(f, "ChargingEnd"),
Self::ParkingEnd { start } => f
.debug_struct("ParkingEnd")
.field("start", &start.as_hms())
.finish(),
Self::StartTime => write!(f, "StartTime"),
Self::EndTime => write!(f, "EndTime"),
Self::MinDuration => write!(f, "MinDuration"),
Self::MaxDuration => write!(f, "MaxDuration"),
Self::MinKwh => write!(f, "MinKwh"),
Self::MaxKwh => write!(f, "MaxKwh"),
}
}
}
#[derive(Debug)]
struct Metrics {
end_date_time: DateTime<Utc>,
start_date_time: DateTime<Utc>,
duration_charging: TimeDelta,
duration_parking: Option<TimeDelta>,
energy_supplied: Kwh,
max_current_supply: Ampere,
max_power_supply: Kw,
}
#[instrument(skip_all)]
fn metrics(elem: &tariff::Versioned<'_>, config: &Config) -> Verdict<(Metrics, chrono_tz::Tz)> {
const SECS_IN_HOUR: Decimal = dec!(3600);
let warnings = warning::Set::new();
let Config {
start_date_time,
end_date_time,
max_power_supply_kw,
requested_kwh: max_energy_battery_kwh,
max_current_supply_amp,
timezone,
} = config;
let duration_session = end_date_time.signed_duration_since(start_date_time);
debug!("duration_session: {}", duration_session.as_hms());
if duration_session.num_seconds().is_negative() {
return warnings.bail(Warning::StartDateTimeIsAfterEndDateTime, elem.as_element());
}
if duration_session.num_seconds() < MIN_CS_DURATION_SECS {
return warnings.bail(Warning::DurationBelowMinimum, elem.as_element());
}
let duration_full_charge_hours = some_dec_or_bail!(
elem,
max_energy_battery_kwh.checked_div(*max_power_supply_kw),
warnings,
"Unable to calculate changing time"
);
debug!(
"duration_full_charge: {}",
duration_full_charge_hours.as_hms()
);
let charging_duration_hours =
Decimal::min(duration_full_charge_hours, duration_session.to_hours_dec());
let power_supplied_kwh = some_dec_or_bail!(
elem,
max_energy_battery_kwh.checked_div(charging_duration_hours),
warnings,
"Unable to calculate the power supplied during the charging time"
);
let charging_duration_secs = some_dec_or_bail!(
elem,
charging_duration_hours.checked_mul(SECS_IN_HOUR),
warnings,
"Unable to convert charging time from hours to seconds"
);
let charging_duration_secs = some_dec_or_bail!(
elem,
charging_duration_secs.round().to_i64(),
warnings,
"Unable to convert charging duration Decimal to i64"
);
let duration_charging = TimeDelta::seconds(charging_duration_secs);
let duration_parking = some_dec_or_bail!(
elem,
duration_session.checked_sub(&duration_charging),
warnings,
"Unable to calculate `idle_duration`"
);
debug!(
"duration_charging: {}, duration_parking: {}",
duration_charging.as_hms(),
duration_parking.as_hms()
);
let metrics = Metrics {
end_date_time: *end_date_time,
start_date_time: *start_date_time,
duration_charging,
duration_parking: Some(duration_parking).filter(|dt| dt.num_seconds().is_positive()),
energy_supplied: Kwh::from_decimal(power_supplied_kwh),
max_current_supply: Ampere::from_decimal(*max_current_supply_amp),
max_power_supply: Kw::from_decimal(*max_power_supply_kw),
};
Ok((metrics, *timezone).into_caveat(warnings))
}
fn is_tariff_active(cdr_start: &DateTime<Utc>, tariff: &tariff::v221::Tariff<'_>) -> bool {
match (tariff.start_date_time, tariff.end_date_time) {
(None, None) => true,
(None, Some(end)) => (..end).contains(cdr_start),
(Some(start), None) => (start..).contains(cdr_start),
(Some(start), Some(end)) => (start..end).contains(cdr_start),
}
}
#[derive(Debug)]
pub enum Warning {
Decimal(&'static str),
DurationBelowMinimum,
Price(price::Warning),
StartDateTimeIsAfterEndDateTime,
Tariff(tariff::Warning),
}
impl crate::Warning for Warning {
fn id(&self) -> warning::Id {
match self {
Self::Decimal(_) => warning::Id::from_static("decimal_error"),
Self::DurationBelowMinimum => warning::Id::from_static("duration_below_minimum"),
Self::Price(kind) => kind.id(),
Self::StartDateTimeIsAfterEndDateTime => {
warning::Id::from_static("start_time_after_end_time")
}
Self::Tariff(kind) => kind.id(),
}
}
}
impl fmt::Display for Warning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decimal(msg) => f.write_str(msg),
Self::DurationBelowMinimum => write!(
f,
"The duration of the chargesession is below the minimum: {MIN_CS_DURATION_SECS}"
),
Self::Price(warnings) => {
write!(f, "Price warnings: {warnings:?}")
}
Self::StartDateTimeIsAfterEndDateTime => {
write!(f, "The `start_date_time` is after the `end_date_time`")
}
Self::Tariff(warnings) => {
write!(f, "Tariff warnings: {warnings:?}")
}
}
}
}
from_warning_all!(
tariff::Warning => Warning::Tariff,
price::Warning => Warning::Price
);