Skip to main content

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}