1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
//! The normalised view of a session that the pricing engine works on.
//!
//! A CDR and a Session carry the same information for pricing purposes, and OCPI 2.2.1 and 2.3.0
//! differ only in the shape of `Price`, which is an *output* of pricing rather than an input. So
//! the engine takes one [`PricedSession`], and each wire type knows how to produce one.
use crate::types::{DateTime, Number};
use super::TimeZone;
/// One charging period, reduced to what pricing needs.
#[derive(Clone, Debug, PartialEq)]
pub struct PricedPeriod {
/// Start of the period. The period ends when the next one starts.
pub start: DateTime,
/// Energy charged during this period, in kWh.
pub energy_kwh: Number,
/// Time spent charging during this period, in hours.
pub charging_hours: Number,
/// Time spent parked and not charging during this period, in hours.
pub parking_hours: Number,
/// Time the EVSE was reserved during this period, in hours.
pub reservation_hours: Number,
/// The highest current drawn during this period, in A, if measured.
pub max_current_a: Option<Number>,
/// The lowest current drawn during this period, in A, if measured.
pub min_current_a: Option<Number>,
/// The highest power drawn during this period, in kW, if measured.
pub max_power_kw: Option<Number>,
/// The lowest power drawn during this period, in kW, if measured.
pub min_power_kw: Option<Number>,
/// The Tariff that applies to this period, when the CPO said which one.
pub tariff_id: Option<String>,
}
impl PricedPeriod {
/// A period with a start time and nothing consumed.
#[must_use]
pub fn new(start: DateTime) -> Self {
Self {
start,
energy_kwh: Number::ZERO,
charging_hours: Number::ZERO,
parking_hours: Number::ZERO,
reservation_hours: Number::ZERO,
max_current_a: None,
min_current_a: None,
max_power_kw: None,
min_power_kw: None,
tariff_id: None,
}
}
/// The current to evaluate a `min_current`/`max_current` restriction against.
///
/// > *`min_current`: Sum of the minimum current (in Amperes) over all phases … When the EV is
/// > charging with more than, or equal to, the defined amount of current, this TariffElement
/// > is/becomes active.*
///
/// The restrictions describe the current *during* the period, so the measured maximum is used
/// for a lower bound and the measured minimum for an upper bound — the pair that makes the
/// restriction hold for the whole period.
#[must_use]
pub fn current_for_lower_bound(&self) -> Option<Number> {
self.max_current_a.or(self.min_current_a)
}
/// The current to evaluate an upper bound against. See
/// [`current_for_lower_bound`](Self::current_for_lower_bound).
#[must_use]
pub fn current_for_upper_bound(&self) -> Option<Number> {
self.min_current_a.or(self.max_current_a)
}
/// The power to evaluate a `min_power` restriction against, in kW.
#[must_use]
pub fn power_for_lower_bound(&self) -> Option<Number> {
self.max_power_kw.or(self.min_power_kw)
}
/// The power to evaluate a `max_power` restriction against, in kW.
#[must_use]
pub fn power_for_upper_bound(&self) -> Option<Number> {
self.min_power_kw.or(self.max_power_kw)
}
}
/// A session reduced to what the pricing engine needs.
///
/// Build one with [`PricedSession::from_cdr`] or [`PricedSession::from_session`], or by hand for
/// a "what would this cost?" calculation that has no CDR yet.
#[derive(Clone, Debug, PartialEq)]
pub struct PricedSession {
/// When the session started, in UTC.
pub start: DateTime,
/// When the session ended, in UTC, if it has.
pub end: Option<DateTime>,
/// The charging periods, in order.
pub periods: Vec<PricedPeriod>,
/// The time zone of the Location, which the local-time restrictions are expressed in.
pub time_zone: TimeZone,
/// The `ProfileType` the driver selected, which decides which `Tariff.type` applies.
pub profile_type: Option<crate::v2_3_0::sessions::ProfileType>,
/// Whether the driver used ad-hoc payment rather than a contract.
pub ad_hoc_payment: bool,
/// Whether a reservation that was made expired before charging started.
///
/// Selects between the `RESERVATION` and `RESERVATION_EXPIRES` tariff elements.
pub reservation_expired: bool,
}
impl PricedSession {
/// A session with no periods, for building up by hand.
#[must_use]
pub fn new(start: DateTime, time_zone: TimeZone) -> Self {
Self {
start,
end: None,
periods: Vec::new(),
time_zone,
profile_type: None,
ad_hoc_payment: false,
reservation_expired: false,
}
}
/// Adds a charging period.
#[must_use]
pub fn with_period(mut self, period: PricedPeriod) -> Self {
self.periods.push(period);
self
}
/// Sets the end of the session.
#[must_use]
pub const fn ending(mut self, end: DateTime) -> Self {
self.end = Some(end);
self
}
/// The total energy across all periods, in kWh.
#[must_use]
pub fn total_energy_kwh(&self) -> Number {
self.periods.iter().map(|p| p.energy_kwh).sum()
}
/// The total charging time across all periods, in hours.
#[must_use]
pub fn total_charging_hours(&self) -> Number {
self.periods.iter().map(|p| p.charging_hours).sum()
}
/// The total parking time across all periods, in hours.
#[must_use]
pub fn total_parking_hours(&self) -> Number {
self.periods.iter().map(|p| p.parking_hours).sum()
}
/// The total reservation time across all periods, in hours.
#[must_use]
pub fn total_reservation_hours(&self) -> Number {
self.periods.iter().map(|p| p.reservation_hours).sum()
}
/// The energy charged before `index`, for a `min_kwh`/`max_kwh` restriction.
#[must_use]
pub fn energy_before(&self, index: usize) -> Number {
self.periods.iter().take(index).map(|p| p.energy_kwh).sum()
}
/// The session duration up to the start of period `index`, in seconds.
///
/// > *`min_duration`: Minimum duration in seconds the Charging Session MUST last.*
#[must_use]
pub fn duration_before(&self, index: usize) -> i64 {
self.periods.get(index).map_or(0, |p| p.start.unix_timestamp() - self.start.unix_timestamp())
}
/// The start of the first period that does not begin after the one before it, if any.
///
/// The engine reads the periods as a timeline: a period's duration is the gap to the next
/// one, and `step_size` applies to *"the last relevant PriceComponent"*. Neither means
/// anything if the list is out of order, which is a thing that happens when a CPO merges
/// period streams from more than one source.
#[must_use]
pub fn first_out_of_order(&self) -> Option<DateTime> {
self.periods.windows(2).find(|pair| pair[1].start <= pair[0].start).map(|pair| pair[1].start)
}
/// The end of period `index`: the start of the next one, or the end of the session.
#[must_use]
pub fn period_end(&self, index: usize) -> Option<DateTime> {
self.periods.get(index + 1).map(|p| p.start).or(self.end)
}
}
#[cfg(feature = "v2_3_0")]
mod from_v2_3_0 {
use super::{PricedPeriod, PricedSession};
use crate::tariffs::TimeZone;
use crate::types::Number;
use crate::v2_3_0::cdrs::{Cdr, CdrDimensionType, ChargingPeriod};
use crate::v2_3_0::sessions::Session;
fn period_from(source: &ChargingPeriod) -> PricedPeriod {
let volume = |t: CdrDimensionType| source.volume(t).unwrap_or(Number::ZERO);
PricedPeriod {
start: source.start_date_time,
energy_kwh: volume(CdrDimensionType::Energy),
charging_hours: volume(CdrDimensionType::Time),
parking_hours: volume(CdrDimensionType::ParkingTime),
// The `bookings` branch splits reserved time in two. `RESERVATION_EXPIRES` is the
// same quantity as `RESERVATION_TIME` — *"time the EVSE has been reserved and not yet
// been in use for this customer"* — differing only in that the reservation then ran
// out, which is what `PricedSession::reservation_expired` records. `RESERVATION_OVERTIME`
// is deliberately **not** added: it is time *after* the reservation, and the branch
// does not say which Tariff dimension prices it, so folding it into reserved time
// would bill it at a rate nothing in the specification puts it at.
reservation_hours: volume(CdrDimensionType::ReservationTime)
+ volume(CdrDimensionType::ReservationExpires),
max_current_a: source.volume(CdrDimensionType::MaxCurrent),
min_current_a: source.volume(CdrDimensionType::MinCurrent),
max_power_kw: source.volume(CdrDimensionType::MaxPower),
min_power_kw: source.volume(CdrDimensionType::MinPower),
tariff_id: source.tariff_id.as_ref().map(|t| t.as_str().to_owned()),
}
}
/// Whether the periods record a reservation that ran out before charging began.
///
/// The `RESERVATION_EXPIRES` dimension is the CPO saying so, and it is what selects the
/// `RESERVATION_EXPIRES` Tariff Element over the `RESERVATION` one. Everything else about the
/// session — `profile_type`, `ad_hoc_payment` — is not on a CDR at all and stays for the
/// caller to set.
fn expired(periods: &[ChargingPeriod]) -> bool {
periods.iter().any(|p| p.volume(CdrDimensionType::ReservationExpires).is_some())
}
impl PricedSession {
/// Builds the pricing input from an OCPI 2.3.0 CDR.
///
/// The CDR does not carry the Location's time zone — it is not one of the fields
/// `CdrLocation` keeps — so it has to be supplied. Use the `time_zone` of the
/// [`Location`](crate::v2_3_0::locations::Location) the session took place at.
#[must_use]
pub fn from_cdr(cdr: &Cdr, time_zone: TimeZone) -> Self {
Self {
start: cdr.start_date_time,
end: Some(cdr.end_date_time),
periods: cdr.charging_periods.iter().map(period_from).collect(),
time_zone,
profile_type: None,
ad_hoc_payment: false,
reservation_expired: expired(&cdr.charging_periods),
}
}
/// Builds the pricing input from an OCPI 2.3.0 Session.
#[must_use]
pub fn from_session(session: &Session, time_zone: TimeZone) -> Self {
Self {
start: session.start_date_time,
end: session.end_date_time,
periods: session.charging_periods.iter().map(period_from).collect(),
time_zone,
profile_type: None,
ad_hoc_payment: false,
reservation_expired: expired(&session.charging_periods),
}
}
}
}
#[cfg(feature = "v2_2_1")]
mod from_v2_2_1 {
use super::{PricedPeriod, PricedSession};
use crate::tariffs::TimeZone;
use crate::types::Number;
use crate::v2_2_1::cdrs::{Cdr, CdrDimensionType};
use crate::v2_2_1::sessions::Session;
impl PricedSession {
/// Builds the pricing input from an OCPI 2.2.1 CDR.
///
/// The charging period types are wire-identical between 2.2.1 and 2.3.0, so this reuses
/// the same reduction.
#[must_use]
pub fn from_cdr_v2_2_1(cdr: &Cdr, time_zone: TimeZone) -> Self {
let periods = cdr
.charging_periods
.iter()
.map(|source| {
let volume = |t: CdrDimensionType| source.volume(t).unwrap_or(Number::ZERO);
PricedPeriod {
start: source.start_date_time,
energy_kwh: volume(CdrDimensionType::Energy),
charging_hours: volume(CdrDimensionType::Time),
parking_hours: volume(CdrDimensionType::ParkingTime),
reservation_hours: volume(CdrDimensionType::ReservationTime),
max_current_a: source.volume(CdrDimensionType::MaxCurrent),
min_current_a: source.volume(CdrDimensionType::MinCurrent),
max_power_kw: source.volume(CdrDimensionType::MaxPower),
min_power_kw: source.volume(CdrDimensionType::MinPower),
tariff_id: source.tariff_id.as_ref().map(|t| t.as_str().to_owned()),
}
})
.collect();
Self {
start: cdr.start_date_time,
end: Some(cdr.end_date_time),
periods,
time_zone,
profile_type: None,
ad_hoc_payment: false,
reservation_expired: false,
}
}
/// Builds the pricing input from an OCPI 2.2.1 Session.
#[must_use]
pub fn from_session_v2_2_1(session: &Session, time_zone: TimeZone) -> Self {
let mut out = Self::new(session.start_date_time, time_zone);
out.end = session.end_date_time;
out.periods = session
.charging_periods
.iter()
.map(|source| {
let volume = |t: CdrDimensionType| source.volume(t).unwrap_or(Number::ZERO);
PricedPeriod {
start: source.start_date_time,
energy_kwh: volume(CdrDimensionType::Energy),
charging_hours: volume(CdrDimensionType::Time),
parking_hours: volume(CdrDimensionType::ParkingTime),
reservation_hours: volume(CdrDimensionType::ReservationTime),
max_current_a: source.volume(CdrDimensionType::MaxCurrent),
min_current_a: source.volume(CdrDimensionType::MinCurrent),
max_power_kw: source.volume(CdrDimensionType::MaxPower),
min_power_kw: source.volume(CdrDimensionType::MinPower),
tariff_id: source.tariff_id.as_ref().map(|t| t.as_str().to_owned()),
}
})
.collect();
out
}
}
}