use ocpp_core::DateTime;
use ocpp21::{
ChargingPeriod, CostDetails, CostDimension, CostDimensionEnum, DayOfWeekEnum, Price, Tariff,
TariffConditions, TariffConditionsFixed, TariffCostEnum, TaxRate, TotalCost, TotalPrice,
TotalUsage,
};
use crate::tz::Zone;
#[derive(Debug, Clone, Default)]
pub struct Sample {
pub at: DateTime,
pub energy_wh: Option<f64>,
pub power_w: Option<f64>,
pub current_a: Option<f64>,
pub charging_state: Option<String>,
}
pub struct Input<'a> {
pub tariffs: &'a [(DateTime, Tariff)],
pub start: DateTime,
pub end: DateTime,
pub samples: &'a [Sample],
pub zone: &'a Zone,
pub evse_kind: Option<&'a str>,
pub payment_brand: Option<&'a str>,
pub payment_recognition: Option<&'a str>,
pub reserved_at: Option<DateTime>,
pub final_: bool,
}
pub fn tax_factor(rates: &[TaxRate]) -> f64 {
let mut stacks: Vec<i64> = rates.iter().map(|r| r.stack.unwrap_or(0)).collect();
stacks.sort_unstable();
stacks.dedup();
stacks
.iter()
.map(|s| {
1.0 + rates
.iter()
.filter(|r| r.stack.unwrap_or(0) == *s)
.map(|r| r.tax)
.sum::<f64>()
/ 100.0
})
.product()
}
fn clean(v: f64) -> f64 {
(v * 1e6).round() / 1e6
}
fn tod_seconds(s: &str) -> Option<u32> {
let (h, m) = s.split_once(':')?;
let (h, m): (u32, u32) = (h.parse().ok()?, m.parse().ok()?);
(h < 24 && m < 60).then_some(h * 3600 + m * 60)
}
fn weekday_name(d: DayOfWeekEnum) -> u32 {
match d {
DayOfWeekEnum::Monday => 0,
DayOfWeekEnum::Tuesday => 1,
DayOfWeekEnum::Wednesday => 2,
DayOfWeekEnum::Thursday => 3,
DayOfWeekEnum::Friday => 4,
DayOfWeekEnum::Saturday => 5,
DayOfWeekEnum::Sunday => 6,
}
}
fn calendar_matches(
start_tod: Option<&str>,
end_tod: Option<&str>,
days: Option<&[DayOfWeekEnum]>,
from: Option<&str>,
to: Option<&str>,
zone: &Zone,
t: DateTime,
) -> bool {
let local = zone.local(t.unix_seconds());
if start_tod.is_some() || end_tod.is_some() {
let s = start_tod.and_then(tod_seconds).unwrap_or(0);
let e = end_tod.and_then(tod_seconds).unwrap_or(86_400);
let sod = local.second_of_day;
let inside = match s.cmp(&e) {
std::cmp::Ordering::Less => sod >= s && sod < e,
std::cmp::Ordering::Greater => sod >= s || sod < e,
std::cmp::Ordering::Equal => true,
};
if !inside {
return false;
}
}
if let Some(days) = days
&& !days.iter().any(|d| weekday_name(*d) == local.weekday)
{
return false;
}
let date = format!("{:04}-{:02}-{:02}", local.year, local.month, local.day);
from.is_none_or(|f| date.as_str() >= f) && to.is_none_or(|to| date.as_str() < to)
}
#[derive(Debug, Clone, Copy, Default)]
struct Context {
energy_used: f64,
elapsed: f64,
charging_time: f64,
idle_time: f64,
power: Option<f64>,
current: Option<f64>,
}
fn in_range(v: Option<f64>, min: Option<f64>, max: Option<f64>) -> bool {
if min.is_none() && max.is_none() {
return true;
}
let Some(v) = v else { return false };
min.is_none_or(|m| v >= m) && max.is_none_or(|m| v < m)
}
fn conditions_match(c: &TariffConditions, i: &Input<'_>, t: DateTime, ctx: &Context) -> bool {
calendar_matches(
c.start_time_of_day.as_deref(),
c.end_time_of_day.as_deref(),
c.day_of_week.as_deref(),
c.valid_from_date.as_deref(),
c.valid_to_date.as_deref(),
i.zone,
t,
) && c.evse_kind.is_none_or(|k| i.evse_kind == Some(k.as_str()))
&& in_range(Some(ctx.energy_used), c.min_energy, c.max_energy)
&& in_range(ctx.current, c.min_current, c.max_current)
&& in_range(ctx.power, c.min_power, c.max_power)
&& in_range(
Some(ctx.elapsed),
c.min_time.map(|v| v as f64),
c.max_time.map(|v| v as f64),
)
&& in_range(
Some(ctx.charging_time),
c.min_charging_time.map(|v| v as f64),
c.max_charging_time.map(|v| v as f64),
)
&& in_range(
Some(ctx.idle_time),
c.min_idle_time.map(|v| v as f64),
c.max_idle_time.map(|v| v as f64),
)
}
fn fixed_conditions_match(c: &TariffConditionsFixed, i: &Input<'_>, t: DateTime) -> bool {
calendar_matches(
c.start_time_of_day.as_deref(),
c.end_time_of_day.as_deref(),
c.day_of_week.as_deref(),
c.valid_from_date.as_deref(),
c.valid_to_date.as_deref(),
i.zone,
t,
) && c.evse_kind.is_none_or(|k| i.evse_kind == Some(k.as_str()))
&& c.payment_brand
.as_deref()
.is_none_or(|b| i.payment_brand == Some(b))
&& c.payment_recognition
.as_deref()
.is_none_or(|r| i.payment_recognition == Some(r))
}
fn energy_at(samples: &[Sample], t: DateTime) -> f64 {
let known: Vec<(DateTime, f64)> = samples
.iter()
.filter_map(|s| s.energy_wh.map(|e| (s.at, e)))
.collect();
let Some(first) = known.first() else {
return 0.0;
};
if t <= first.0 {
return first.1;
}
for w in known.windows(2) {
let ((t0, e0), (t1, e1)) = (w[0], w[1]);
if t <= t1 {
let span = (t1.unix_micros() - t0.unix_micros()) as f64;
return if span <= 0.0 {
e1
} else {
e0 + (e1 - e0) * (t.unix_micros() - t0.unix_micros()) as f64 / span
};
}
}
known.last().unwrap().1
}
fn last_sample_at(samples: &[Sample], t: DateTime) -> Option<&Sample> {
samples.iter().take_while(|s| s.at <= t).last()
}
#[derive(Clone, Copy, PartialEq)]
enum Activity {
Charging,
Idle,
None,
}
fn activity(samples: &[Sample], t: DateTime, delta_energy: f64) -> Activity {
match samples
.iter()
.take_while(|s| s.at <= t)
.filter_map(|s| s.charging_state.as_deref())
.last()
{
Some("Charging") => Activity::Charging,
Some("SuspendedEV" | "SuspendedEVSE" | "EVConnected") => Activity::Idle,
Some(_) => Activity::None,
None if delta_energy > 0.5 => Activity::Charging,
None => Activity::Idle,
}
}
fn tariff_at(tariffs: &[(DateTime, Tariff)], t: DateTime) -> &Tariff {
tariffs
.iter()
.take_while(|(from, _)| *from <= t)
.last()
.map(|(_, tf)| tf)
.unwrap_or(&tariffs[0].1)
}
fn local_boundaries(tariffs: &[(DateTime, Tariff)]) -> Vec<u32> {
let mut out = vec![0];
for (_, t) in tariffs {
let conds = t
.energy
.iter()
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref()))
.chain(
t.charging_time
.iter()
.chain(t.idle_time.iter())
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref())),
);
for c in conds {
out.extend(c.start_time_of_day.as_deref().and_then(tod_seconds));
out.extend(c.end_time_of_day.as_deref().and_then(tod_seconds));
}
}
out.sort_unstable();
out.dedup();
out
}
fn breakpoints(i: &Input<'_>) -> Vec<DateTime> {
let mut points: Vec<DateTime> = vec![i.start, i.end];
points.extend(
i.samples
.iter()
.map(|s| s.at)
.filter(|t| *t > i.start && *t < i.end),
);
points.extend(
i.tariffs
.iter()
.map(|(t, _)| *t)
.filter(|t| *t > i.start && *t < i.end),
);
let tods = local_boundaries(i.tariffs);
let mut t = i.start.unix_seconds();
let end = i.end.unix_seconds();
for _ in 0..100_000 {
let local = i.zone.local(t);
let sod = local.second_of_day;
let next_tod = tods.iter().copied().find(|&b| b > sod).unwrap_or(86_400);
let mut next = t + (next_tod - sod) as i64;
if let Some(tr) = i.zone.next_transition_after(t)
&& tr < next
{
next = tr;
}
if next >= end {
break;
}
points.push(DateTime::from_unix_seconds(next));
t = next;
}
points.sort();
points.dedup();
points
}
#[derive(Default)]
struct Thresholds {
energy: Vec<f64>,
elapsed: Vec<f64>,
charging: Vec<f64>,
idle: Vec<f64>,
}
fn thresholds(tariffs: &[(DateTime, Tariff)]) -> Thresholds {
let mut th = Thresholds::default();
for (_, t) in tariffs {
let conds = t
.energy
.iter()
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref()))
.chain(
t.charging_time
.iter()
.chain(t.idle_time.iter())
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref())),
);
for c in conds {
th.energy
.extend(c.min_energy.into_iter().chain(c.max_energy));
th.elapsed
.extend(c.min_time.into_iter().chain(c.max_time).map(|v| v as f64));
th.charging.extend(
c.min_charging_time
.into_iter()
.chain(c.max_charging_time)
.map(|v| v as f64),
);
th.idle.extend(
c.min_idle_time
.into_iter()
.chain(c.max_idle_time)
.map(|v| v as f64),
);
}
}
th
}
#[derive(Default)]
struct Acc {
excl: f64,
incl: f64,
rates: Option<Vec<TaxRate>>,
}
impl Acc {
fn add(&mut self, excl: f64, rates: Option<&Vec<TaxRate>>) {
let rates_slice = rates.map(Vec::as_slice).unwrap_or(&[]);
self.excl += excl;
self.incl += excl * tax_factor(rates_slice);
if self.rates.is_none() {
self.rates = rates.cloned();
}
}
fn price(&self, used: bool) -> Option<Price> {
used.then(|| Price {
excl_tax: Some(clean(self.excl)),
incl_tax: Some(clean(self.incl)),
tax_rates: self.rates.clone(),
custom_data: None,
})
}
}
#[derive(Default)]
struct Totals {
fixed: Acc,
energy: Acc,
charging: Acc,
idle: Acc,
reservation_time: Acc,
reservation_fixed: Acc,
}
impl Totals {
fn excl(&self) -> f64 {
self.fixed.excl
+ self.energy.excl
+ self.charging.excl
+ self.idle.excl
+ self.reservation_time.excl
+ self.reservation_fixed.excl
}
fn incl(&self) -> f64 {
self.fixed.incl
+ self.energy.incl
+ self.charging.incl
+ self.idle.incl
+ self.reservation_time.incl
+ self.reservation_fixed.incl
}
}
struct PeriodAcc {
key: (String, Option<usize>, Option<usize>, Option<usize>),
start: DateTime,
energy: f64,
charging: f64,
idle: f64,
min_power: Option<f64>,
max_power: Option<f64>,
min_current: Option<f64>,
max_current: Option<f64>,
}
impl PeriodAcc {
fn finish(self, with_power: bool, with_current: bool) -> ChargingPeriod {
let mut dims = Vec::new();
let mut dim = |t, v: f64| {
dims.push(CostDimension {
r#type: t,
volume: clean(v),
custom_data: None,
})
};
if self.energy > 0.0 {
dim(CostDimensionEnum::Energy, self.energy);
}
if self.charging > 0.0 {
dim(CostDimensionEnum::ChargingTime, self.charging.round());
}
if self.idle > 0.0 {
dim(CostDimensionEnum::IdleTIme, self.idle.round());
}
if with_power {
self.min_power
.into_iter()
.for_each(|v| dim(CostDimensionEnum::MinPower, v));
self.max_power
.into_iter()
.for_each(|v| dim(CostDimensionEnum::MaxPower, v));
}
if with_current {
self.min_current
.into_iter()
.for_each(|v| dim(CostDimensionEnum::MinCurrent, v));
self.max_current
.into_iter()
.for_each(|v| dim(CostDimensionEnum::MaxCurrent, v));
}
ChargingPeriod {
dimensions: (!dims.is_empty()).then_some(dims),
tariff_id: Some(self.key.0),
start_period: self.start,
custom_data: None,
}
}
}
fn uses_power_or_current(tariffs: &[(DateTime, Tariff)]) -> (bool, bool) {
let mut power = false;
let mut current = false;
for (_, t) in tariffs {
let conds = t
.energy
.iter()
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref()))
.chain(
t.charging_time
.iter()
.chain(t.idle_time.iter())
.flat_map(|e| e.prices.iter().filter_map(|p| p.conditions.as_ref())),
);
for c in conds {
power |= c.min_power.is_some() || c.max_power.is_some();
current |= c.min_current.is_some() || c.max_current.is_some();
}
}
(power, current)
}
pub fn calculate(i: &Input<'_>) -> CostDetails {
assert!(!i.tariffs.is_empty(), "at least one tariff");
let first = &i.tariffs[0].1;
let currency = first.currency.clone();
let mut failure: Option<String> = i
.tariffs
.iter()
.any(|(_, t)| t.currency != currency)
.then(|| "tariff currency changed during the transaction".to_owned());
let mut totals = Totals::default();
if let Some(f) = &first.fixed_fee
&& let Some(p) = f.prices.iter().find(|p| {
p.conditions
.as_ref()
.is_none_or(|c| fixed_conditions_match(c, i, i.start))
})
{
totals.fixed.add(p.price_fixed, f.tax_rates.as_ref());
}
let mut reservation_secs = None;
if let Some(reserved_at) = i.reserved_at {
if let Some(f) = &first.reservation_fixed
&& let Some(p) = f.prices.iter().find(|p| {
p.conditions
.as_ref()
.is_none_or(|c| fixed_conditions_match(c, i, i.start))
})
{
totals
.reservation_fixed
.add(p.price_fixed, f.tax_rates.as_ref());
}
let secs = ((i.start.unix_micros() - reserved_at.unix_micros()) / 1_000_000).max(0);
reservation_secs = Some(secs);
if let Some(rt) = &first.reservation_time
&& let Some(p) = rt.prices.iter().find(|p| {
p.conditions
.as_ref()
.is_none_or(|c| conditions_match(c, i, i.start, &Context::default()))
})
{
totals
.reservation_time
.add(secs as f64 / 60.0 * p.price_minute, rt.tax_rates.as_ref());
}
}
let th = thresholds(i.tariffs);
let (with_power, with_current) = uses_power_or_current(i.tariffs);
let base = breakpoints(i);
let start_energy = energy_at(i.samples, i.start);
let mut ctx = Context::default();
let mut usage = (0.0f64, 0.0f64, 0.0f64); let mut periods: Vec<ChargingPeriod> = Vec::new();
let mut current: Option<PeriodAcc> = None;
let mut frozen = matches_max(first, &totals);
let mut type_of_cost = if frozen {
TariffCostEnum::MaxCost
} else {
TariffCostEnum::NormalCost
};
let mut queue: std::collections::VecDeque<(DateTime, DateTime)> =
base.windows(2).map(|w| (w[0], w[1])).collect();
while let Some((a, b)) = queue.pop_front() {
let dt = (b.unix_micros() - a.unix_micros()) as f64 / 1e6;
if dt <= 0.0 {
continue;
}
let e_a = energy_at(i.samples, a);
let de = (energy_at(i.samples, b) - e_a).max(0.0);
let act = activity(i.samples, a, de);
ctx.energy_used = e_a - start_energy;
ctx.elapsed = (a.unix_micros() - i.start.unix_micros()) as f64 / 1e6;
let lerp = |frac: f64| {
DateTime::from_unix_micros(
a.unix_micros() + ((b.unix_micros() - a.unix_micros()) as f64 * frac) as i64,
)
};
let mut split = None;
let mut consider = |frac: f64| {
if frac > 1e-9 && frac < 1.0 - 1e-9 {
split = Some(split.map_or(frac, |s: f64| s.min(frac)));
}
};
if de > 0.0 {
th.energy
.iter()
.for_each(|&x| consider((x - ctx.energy_used) / de));
}
th.elapsed
.iter()
.for_each(|&x| consider((x - ctx.elapsed) / dt));
if act == Activity::Charging {
th.charging
.iter()
.for_each(|&x| consider((x - ctx.charging_time) / dt));
}
if act == Activity::Idle {
th.idle
.iter()
.for_each(|&x| consider((x - ctx.idle_time) / dt));
}
if let Some(frac) = split {
let m = lerp(frac);
if m > a && m < b {
queue.push_front((m, b));
queue.push_front((a, m));
continue;
}
}
let sample = last_sample_at(i.samples, a);
ctx.power = sample
.and_then(|s| s.power_w)
.or_else(|| (act == Activity::Charging).then_some(de * 3600.0 / dt));
ctx.current = sample.and_then(|s| s.current_a);
let tariff = tariff_at(i.tariffs, a);
let energy_idx = tariff.energy.as_ref().and_then(|e| {
e.prices.iter().position(|p| {
p.conditions
.as_ref()
.is_none_or(|c| conditions_match(c, i, a, &ctx))
})
});
let time_list = match act {
Activity::Charging => tariff.charging_time.as_ref(),
Activity::Idle => tariff.idle_time.as_ref(),
Activity::None => None,
};
let time_idx = time_list.and_then(|tt| {
tt.prices.iter().position(|p| {
p.conditions
.as_ref()
.is_none_or(|c| conditions_match(c, i, a, &ctx))
})
});
let key = (
tariff.tariff_id.clone(),
energy_idx,
if act == Activity::Charging {
time_idx
} else {
None
},
if act == Activity::Idle {
time_idx
} else {
None
},
);
if current.as_ref().is_none_or(|c| c.key != key) {
if let Some(c) = current.take() {
periods.push(c.finish(with_power, with_current));
}
current = Some(PeriodAcc {
key: key.clone(),
start: a,
energy: 0.0,
charging: 0.0,
idle: 0.0,
min_power: None,
max_power: None,
min_current: None,
max_current: None,
});
}
let p = current.as_mut().expect("set above");
p.energy += de;
match act {
Activity::Charging => {
p.charging += dt;
ctx.charging_time += dt;
usage.1 += dt;
}
Activity::Idle => {
p.idle += dt;
ctx.idle_time += dt;
usage.2 += dt;
}
Activity::None => {}
}
usage.0 += de;
if act == Activity::Charging {
if let Some(pw) = ctx.power {
p.min_power = Some(p.min_power.map_or(pw, |m| m.min(pw)));
p.max_power = Some(p.max_power.map_or(pw, |m| m.max(pw)));
}
if let Some(cu) = ctx.current {
p.min_current = Some(p.min_current.map_or(cu, |m| m.min(cu)));
p.max_current = Some(p.max_current.map_or(cu, |m| m.max(cu)));
}
}
if frozen {
continue; }
let before = (totals.excl(), totals.incl());
let snapshot = [
(totals.energy.excl, totals.energy.incl),
(totals.charging.excl, totals.charging.incl),
(totals.idle.excl, totals.idle.incl),
];
if let (Some(e), Some(idx)) = (&tariff.energy, energy_idx) {
totals
.energy
.add(de / 1000.0 * e.prices[idx].price_kwh, e.tax_rates.as_ref());
}
if let (Some(tt), Some(idx)) = (time_list, time_idx) {
let acc = if act == Activity::Charging {
&mut totals.charging
} else {
&mut totals.idle
};
acc.add(
dt / 60.0 * tt.prices[idx].price_minute,
tt.tax_rates.as_ref(),
);
}
if let Some(scale) = max_cost_scale(first, before, (totals.excl(), totals.incl())) {
for (acc, (excl0, incl0)) in
[&mut totals.energy, &mut totals.charging, &mut totals.idle]
.into_iter()
.zip(snapshot)
{
acc.excl = excl0 + (acc.excl - excl0) * scale;
acc.incl = incl0 + (acc.incl - incl0) * scale;
}
frozen = true;
type_of_cost = TariffCostEnum::MaxCost;
}
}
if let Some(c) = current.take() {
periods.push(c.finish(with_power, with_current));
}
let mut total_cost = TotalCost {
currency: currency.clone(),
type_of_cost,
fixed: totals
.fixed
.price(totals.fixed.excl != 0.0 || first.fixed_fee.is_some()),
energy: totals.energy.price(first.energy.is_some()),
charging_time: totals.charging.price(first.charging_time.is_some()),
idle_time: totals.idle.price(first.idle_time.is_some()),
reservation_time: totals
.reservation_time
.price(i.reserved_at.is_some() && first.reservation_time.is_some()),
reservation_fixed: totals
.reservation_fixed
.price(i.reserved_at.is_some() && first.reservation_fixed.is_some()),
total: TotalPrice {
excl_tax: Some(clean(totals.excl())),
incl_tax: Some(clean(totals.incl())),
custom_data: None,
},
custom_data: None,
};
if i.final_
&& let Some(min) = &first.min_cost
&& (min.excl_tax.is_some_and(|m| totals.excl() < m)
|| min.incl_tax.is_some_and(|m| totals.incl() < m))
{
total_cost = TotalCost {
currency,
type_of_cost: TariffCostEnum::MinCost,
fixed: Some(min.clone()),
energy: None,
charging_time: None,
idle_time: None,
reservation_time: None,
reservation_fixed: None,
total: TotalPrice {
excl_tax: min.excl_tax,
incl_tax: min.incl_tax,
custom_data: None,
},
custom_data: None,
};
}
if i.samples.iter().all(|s| s.energy_wh.is_none())
&& first.energy.is_some()
&& failure.is_none()
{
failure = Some("no energy meter values received for this transaction".into());
}
CostDetails {
charging_periods: (!periods.is_empty()).then_some(periods),
total_cost,
total_usage: TotalUsage {
energy: clean(usage.0),
charging_time: usage.1.round() as i64,
idle_time: usage.2.round() as i64,
reservation_time: reservation_secs,
custom_data: None,
},
failure_to_calculate: failure.is_some().then_some(true),
failure_reason: failure,
custom_data: None,
}
}
fn matches_max(t: &Tariff, totals: &Totals) -> bool {
t.max_cost.as_ref().is_some_and(|m| {
m.excl_tax.is_some_and(|x| totals.excl() >= x)
|| m.incl_tax.is_some_and(|x| totals.incl() >= x)
})
}
fn max_cost_scale(t: &Tariff, before: (f64, f64), after: (f64, f64)) -> Option<f64> {
let m = t.max_cost.as_ref()?;
let frac = |limit: Option<f64>, b: f64, a: f64| {
limit
.filter(|l| a >= *l && a > b)
.map(|l| ((l - b) / (a - b)).clamp(0.0, 1.0))
};
match (
frac(m.excl_tax, before.0, after.0),
frac(m.incl_tax, before.1, after.1),
) {
(Some(x), Some(y)) => Some(x.min(y)),
(x, y) => x.or(y),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tariff(json: serde_json::Value) -> Tariff {
ocpp_core::decode_value(json).unwrap()
}
fn t(s: &str) -> DateTime {
DateTime::parse(s).unwrap()
}
fn sample(at: &str, wh: f64, state: &str) -> Sample {
Sample {
at: t(at),
energy_wh: Some(wh),
charging_state: Some(state.into()),
..Default::default()
}
}
fn run(
tariffs: &[(DateTime, Tariff)],
start: &str,
end: &str,
samples: &[Sample],
zone: &Zone,
final_: bool,
) -> CostDetails {
calculate(&Input {
tariffs,
start: t(start),
end: t(end),
samples,
zone,
evse_kind: None,
payment_brand: None,
payment_recognition: None,
reserved_at: None,
final_,
})
}
fn excl(p: &Option<Price>) -> f64 {
p.as_ref().unwrap().excl_tax.unwrap()
}
fn incl(p: &Option<Price>) -> f64 {
p.as_ref().unwrap().incl_tax.unwrap()
}
#[test]
fn simple_kwh_tariff_spec_example() {
let tf = tariff(
serde_json::json!({ "tariffId": "10", "currency": "USD", "energy": { "taxRates": [{ "type": "federal", "tax": 6.0 }, { "type": "state", "tax": 4.0 }], "prices": [{ "priceKwh": 0.25 }] } }),
);
let s = [
sample("2023-04-05T14:01:02Z", 0.0, "Charging"),
sample("2023-04-05T15:01:02Z", 10_000.0, "Charging"),
];
let c = run(
&[(t("2023-04-05T14:01:02Z"), tf)],
"2023-04-05T14:01:02Z",
"2023-04-05T15:01:02Z",
&s,
&Zone::utc(),
true,
);
assert_eq!(excl(&c.total_cost.energy), 2.5);
assert!((incl(&c.total_cost.energy) - 2.75).abs() < 1e-9);
assert_eq!(
c.total_cost
.total
.incl_tax
.map(|v| (v * 100.0).round() / 100.0),
Some(2.75)
);
assert_eq!(c.total_usage.energy, 10_000.0);
let periods = c.charging_periods.unwrap();
assert_eq!(periods.len(), 1);
assert_eq!(periods[0].tariff_id.as_deref(), Some("10"));
assert_eq!(c.total_cost.type_of_cost, TariffCostEnum::NormalCost);
}
#[test]
fn time_of_day_prices_split_periods_in_local_time() {
let tf = tariff(
serde_json::json!({ "tariffId": "11", "currency": "EUR", "energy": { "taxRates": [{ "type": "vat", "tax": 4.0 }],
"prices": [{ "priceKwh": 0.4, "conditions": { "startTimeOfDay": "08:00", "endTimeOfDay": "18:00" } }, { "priceKwh": 0.25 }] } }),
);
let paris = Zone::from_posix("CET-1CEST,M3.5.0,M10.5.0/3").unwrap();
let s = [
sample("2024-06-05T15:30:00Z", 0.0, "Charging"),
sample("2024-06-05T16:30:00Z", 12_000.0, "Charging"),
];
let c = run(
&[(t("2024-06-05T15:30:00Z"), tf)],
"2024-06-05T15:30:00Z",
"2024-06-05T16:30:00Z",
&s,
&paris,
true,
);
let periods = c.charging_periods.unwrap();
assert_eq!(periods.len(), 2);
assert_eq!(periods[1].start_period, t("2024-06-05T16:00:00Z"));
assert!((excl(&c.total_cost.energy) - (6.0 * 0.4 + 6.0 * 0.25)).abs() < 1e-6);
assert!((incl(&c.total_cost.energy) - 3.9 * 1.04).abs() < 1e-6);
}
#[test]
fn charging_time_by_power_fixed_fee_and_idle_fee() {
let tf = tariff(serde_json::json!({ "tariffId": "12", "currency": "EUR",
"fixedFee": { "taxRates": [{ "type": "vat", "tax": 10.0 }], "prices": [{ "priceFixed": 3.0, "conditions": { "paymentRecognition": "CC" } }, { "priceFixed": 2.5 }] },
"chargingTime": { "taxRates": [{ "type": "vat", "tax": 15.0 }], "prices": [
{ "priceMinute": 1.0, "conditions": { "maxPower": 11000 } }, { "priceMinute": 2.0, "conditions": { "minPower": 11000 } }] },
"idleTime": { "prices": [{ "priceMinute": 1.0, "conditions": { "minIdleTime": 300 } }] } }));
let s = [
sample("2024-01-10T14:00:00Z", 0.0, "Charging"),
sample("2024-01-10T15:00:00Z", 10_500.0, "Charging"),
sample("2024-01-10T15:30:00Z", 21_500.0, "SuspendedEV"),
sample("2024-01-10T15:45:00Z", 21_500.0, "SuspendedEV"),
];
let c = run(
&[(t("2024-01-10T14:00:00Z"), tf)],
"2024-01-10T14:00:00Z",
"2024-01-10T15:45:00Z",
&s,
&Zone::utc(),
true,
);
assert_eq!(excl(&c.total_cost.fixed), 2.5);
assert!((incl(&c.total_cost.fixed) - 2.75).abs() < 1e-9);
assert!(
(excl(&c.total_cost.charging_time) - 120.0).abs() < 1e-6,
"{:?}",
c.total_cost.charging_time
);
assert!((incl(&c.total_cost.charging_time) - 138.0).abs() < 1e-6);
assert!(
(excl(&c.total_cost.idle_time) - 10.0).abs() < 1e-6,
"{:?}",
c.total_cost.idle_time
);
assert_eq!(
(c.total_usage.charging_time, c.total_usage.idle_time),
(5400, 900)
);
let periods = c.charging_periods.unwrap();
assert!(periods.iter().any(|p| {
p.dimensions
.as_ref()
.unwrap()
.iter()
.any(|d| d.r#type == CostDimensionEnum::MaxPower)
}));
assert!((c.total_cost.total.excl_tax.unwrap() - 132.5).abs() < 1e-6);
}
#[test]
fn min_and_max_cost() {
let tf = tariff(
serde_json::json!({ "tariffId": "13", "currency": "EUR", "energy": { "prices": [{ "priceKwh": 0.5 }] },
"minCost": { "exclTax": 2.0, "inclTax": 2.4 }, "maxCost": { "exclTax": 10.0 } }),
);
let tariffs = [(t("2024-01-01T10:00:00Z"), tf)];
let s = [
sample("2024-01-01T10:00:00Z", 0.0, "Charging"),
sample("2024-01-01T11:00:00Z", 1000.0, "Charging"),
];
let c = run(
&tariffs,
"2024-01-01T10:00:00Z",
"2024-01-01T11:00:00Z",
&s,
&Zone::utc(),
true,
);
assert_eq!(c.total_cost.type_of_cost, TariffCostEnum::MinCost);
assert_eq!(c.total_cost.total.excl_tax, Some(2.0));
let c = run(
&tariffs,
"2024-01-01T10:00:00Z",
"2024-01-01T11:00:00Z",
&s,
&Zone::utc(),
false,
);
assert_eq!(c.total_cost.type_of_cost, TariffCostEnum::NormalCost);
let s = [
sample("2024-01-01T10:00:00Z", 0.0, "Charging"),
sample("2024-01-01T11:00:00Z", 50_000.0, "Charging"),
];
let c = run(
&tariffs,
"2024-01-01T10:00:00Z",
"2024-01-01T11:00:00Z",
&s,
&Zone::utc(),
true,
);
assert_eq!(c.total_cost.type_of_cost, TariffCostEnum::MaxCost);
assert!((c.total_cost.total.excl_tax.unwrap() - 10.0).abs() < 1e-6);
assert_eq!(c.total_usage.energy, 50_000.0);
}
#[test]
fn tariff_change_and_energy_threshold() {
let a = tariff(
serde_json::json!({ "tariffId": "A", "currency": "EUR", "energy": { "prices": [{ "priceKwh": 0.3, "conditions": { "maxEnergy": 10000 } }, { "priceKwh": 0.2 }] } }),
);
let b = tariff(
serde_json::json!({ "tariffId": "B", "currency": "EUR", "energy": { "prices": [{ "priceKwh": 0.1 }] } }),
);
let s = [
sample("2024-01-01T10:00:00Z", 0.0, "Charging"),
sample("2024-01-01T11:00:00Z", 40_000.0, "Charging"),
];
let tariffs = [
(t("2024-01-01T10:00:00Z"), a),
(t("2024-01-01T10:30:00Z"), b),
];
let c = run(
&tariffs,
"2024-01-01T10:00:00Z",
"2024-01-01T11:00:00Z",
&s,
&Zone::utc(),
true,
);
assert!(
(excl(&c.total_cost.energy) - 7.0).abs() < 1e-6,
"{:?}",
c.total_cost.energy
);
let ids: Vec<_> = c
.charging_periods
.unwrap()
.into_iter()
.map(|p| p.tariff_id.unwrap())
.collect();
assert_eq!(ids, vec!["A", "A", "B"]);
}
#[test]
fn stacked_taxes() {
let rates: Vec<TaxRate> = serde_json::from_value(serde_json::json!([{ "type": "energy", "tax": 10.0, "stack": 0 }, { "type": "vat", "tax": 20.0, "stack": 1 }])).unwrap();
assert!((tax_factor(&rates) - 1.32).abs() < 1e-12);
assert_eq!(tax_factor(&[]), 1.0);
}
}