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