ocpi_kit/tariffs/policy.rs
1//! The decisions OCPI deliberately leaves open: rounding, quantisation, and currency precision.
2//!
3//! > *NOTE: There are no parameters related to price rounding in the Tariff object or any of its
4//! > constituent objects. Nor does the specification text of this module give any requirements
5//! > about how to do price rounding. The reason for this that price rounding has to be done
6//! > according to rules and restrictions set by applicable laws, contracts between the parties
7//! > using OCPI and the currency used. The OCPI specification stays out of these matters.*
8//!
9//! A pricing engine cannot stay out of them, so this crate makes them a **parameter** rather than
10//! a hidden constant. [`PricingPolicy::default`] is a defensible starting point; a party with a
11//! contract that says otherwise changes it and gets an auditable answer either way.
12
13use rust_decimal::RoundingStrategy;
14
15use crate::types::Number;
16#[cfg(test)]
17use crate::types::Validate as _;
18
19/// How and when to round money.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct PricingPolicy {
23 /// Decimal places each dimension's cost is rounded to before the totals are summed.
24 ///
25 /// Default 4, which is the precision the specification names for OCPI `number`s:
26 /// *"Unless mentioned otherwise, numbers use 4 decimals."*
27 pub component_decimals: u32,
28 /// Decimal places the final total is rounded to.
29 ///
30 /// Default 2: the minor unit of the great majority of currencies. Set to 0 for JPY, 3 for
31 /// KWD and the other three-decimal currencies.
32 pub currency_decimals: u32,
33 /// How a value exactly halfway between two representable amounts is resolved.
34 ///
35 /// Default [`RoundingStrategy::MidpointAwayFromZero`] — "round half up" — which is what
36 /// invoicing legislation in most of Europe assumes.
37 pub rounding: RoundingStrategy,
38 /// Decimal places the **quantities** in a [`CostBreakdown`] are reported to.
39 ///
40 /// Default 6, which resolves a second of time (1/3600 h ≈ 0.000278) and a tenth of a watt
41 /// hour, and is comfortably inside what a JSON number carries exactly.
42 ///
43 /// This exists because a measured quantity is not always a short decimal. Summing
44 /// `PARKING_TIME` volumes, or converting seconds to hours, produces values like
45 /// `0.13333333333333333333333333333` — exact as a [`Decimal`](rust_decimal::Decimal), but
46 /// with more significant digits than a JSON number preserves, so a breakdown carrying one
47 /// would fail [`Number::json_round_trips`](crate::types::Number::json_round_trips) and be
48 /// reported as [`Imprecise`](crate::types::ViolationCode::Imprecise) by the crate's own
49 /// validator. An auditable artefact that does not survive being written down is not
50 /// auditable.
51 ///
52 /// Money is **not** rounded to this: costs are computed from the exact quantity and then
53 /// rounded by [`component_decimals`](Self::component_decimals). This setting decides what the
54 /// breakdown *says* was measured, never what was charged.
55 ///
56 /// [`CostBreakdown`]: crate::tariffs::CostBreakdown
57 pub quantity_decimals: u32,
58 /// Whether to apply `step_size` block billing.
59 ///
60 /// Default [`Quantisation::StepSize`]. OCPI 3.0 removes `step_size` in favour of
61 /// full-precision metering, so this is a pluggable stage rather than a hard-wired one:
62 ///
63 /// > *NOTE: The `step_size` field is no longer present in OCPI 3.0. In OCPI 3.0, Parties are
64 /// > advised to measure quantities as precise as required by calibration law and use the full
65 /// > precision of such measurements in cost computation.*
66 pub quantisation: Quantisation,
67}
68
69impl Default for PricingPolicy {
70 fn default() -> Self {
71 Self {
72 component_decimals: 4,
73 currency_decimals: 2,
74 quantity_decimals: 6,
75 rounding: RoundingStrategy::MidpointAwayFromZero,
76 quantisation: Quantisation::StepSize,
77 }
78 }
79}
80
81impl PricingPolicy {
82 /// A policy for a currency with no minor unit, such as JPY.
83 #[must_use]
84 pub fn zero_decimal_currency() -> Self {
85 Self { currency_decimals: 0, ..Self::default() }
86 }
87
88 /// A policy that ignores `step_size`, as OCPI 3.0 will.
89 #[must_use]
90 pub fn without_step_size(mut self) -> Self {
91 self.quantisation = Quantisation::None;
92 self
93 }
94
95 /// Rounds an intermediate per-dimension cost.
96 #[must_use]
97 pub fn round_component(&self, value: Number) -> Number {
98 Number::new(value.get().round_dp_with_strategy(self.component_decimals, self.rounding))
99 }
100
101 /// Rounds a final, presentable amount.
102 #[must_use]
103 pub fn round_currency(&self, value: Number) -> Number {
104 Number::new(value.get().round_dp_with_strategy(self.currency_decimals, self.rounding))
105 }
106
107 /// Rounds a quantity for reporting. See [`quantity_decimals`](Self::quantity_decimals).
108 #[must_use]
109 pub fn round_quantity(&self, value: Number) -> Number {
110 Number::new(value.get().round_dp_with_strategy(self.quantity_decimals, self.rounding))
111 }
112}
113
114/// Whether consumed quantities are billed in `step_size` blocks.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum Quantisation {
118 /// Round each quantity up to the next multiple of the applicable `step_size`, as OCPI 2.x
119 /// prescribes.
120 StepSize,
121 /// Bill the measured quantity exactly, as OCPI 3.0 will.
122 None,
123}
124
125impl Quantisation {
126 /// Rounds `quantity` up to the next multiple of `step_size` units.
127 ///
128 /// `unit_scale` converts the quantity into the unit `step_size` counts: 1000 for `ENERGY`
129 /// (kWh measured, Wh counted) and 3600 for the time dimensions (hours measured, seconds
130 /// counted).
131 ///
132 /// > *Consumed amounts are rounded up to the smallest multiple of `step_size` that is greater
133 /// > than the consumed amount.*
134 #[must_use]
135 pub fn apply(self, quantity: Number, step_size: u32, unit_scale: u32) -> Number {
136 // A `step_size` of 0 carries no meaning — that is the `FLAT` case, and the
137 // specification's own free-of-charge example writes 0 there. A `step_size` of 1 does:
138 // for `ENERGY` it means "billed per 1 Wh", so 115.2 Wh becomes 116 Wh.
139 if self == Self::None || step_size == 0 {
140 return quantity;
141 }
142 let scale = Number::from(unit_scale);
143 let step = Number::from(step_size);
144 let in_units = quantity * scale;
145 let blocks = (in_units / step).get().ceil();
146 Number::new(blocks) * step / scale
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn n(s: &str) -> Number {
155 s.parse().unwrap()
156 }
157
158 #[test]
159 fn energy_is_quantised_in_watt_hours() {
160 // "If someone charges their EV with 115.2 Wh, then they are billed for 116 Wh"
161 assert_eq!(Quantisation::StepSize.apply(n("0.1152"), 1, 1000), n("0.116"));
162 // "When step_size = 25, then the same amount would be billed for 101 to 125 Wh"
163 assert_eq!(Quantisation::StepSize.apply(n("0.1152"), 25, 1000), n("0.125"));
164 // "When step_size = 500, then the same amount will be billed for 1 to 500 Wh"
165 assert_eq!(Quantisation::StepSize.apply(n("0.1152"), 500, 1000), n("0.5"));
166 }
167
168 #[test]
169 fn time_is_quantised_in_seconds() {
170 // 8 minutes with a 300-second step is billed as 10 minutes.
171 let eight_minutes = n("8") / n("60");
172 let billed = Quantisation::StepSize.apply(eight_minutes, 300, 3600);
173 assert_eq!(billed, n("10") / n("60"));
174 // 5.4 kWh with a 500 Wh step becomes 5.5 kWh, the spec's own example.
175 assert_eq!(Quantisation::StepSize.apply(n("5.4"), 500, 1000), n("5.5"));
176 }
177
178 #[test]
179 fn an_exact_multiple_is_left_alone() {
180 assert_eq!(Quantisation::StepSize.apply(n("5.5"), 500, 1000), n("5.5"));
181 assert_eq!(Quantisation::StepSize.apply(n("2"), 1, 1000), n("2"));
182 }
183
184 #[test]
185 fn disabling_quantisation_bills_the_measured_amount() {
186 assert_eq!(Quantisation::None.apply(n("5.4"), 500, 1000), n("5.4"));
187 let policy = PricingPolicy::default().without_step_size();
188 assert_eq!(policy.quantisation, Quantisation::None);
189 }
190
191 #[test]
192 fn a_reported_quantity_survives_being_written_down() {
193 // Eight minutes in hours is a repeating decimal; reporting it verbatim would put a value
194 // in the breakdown that the crate's own validator flags as imprecise.
195 let p = PricingPolicy::default();
196 let eight_minutes = n("8") / n("60");
197 assert!(!eight_minutes.json_round_trips(), "the raw quantity does not");
198 let reported = p.round_quantity(eight_minutes);
199 assert_eq!(reported, n("0.133333"));
200 assert!(reported.json_round_trips());
201 assert!(reported.validate().is_ok());
202 }
203
204 #[test]
205 fn rounding_half_goes_away_from_zero_by_default() {
206 let p = PricingPolicy::default();
207 assert_eq!(p.round_currency(n("2.005")), n("2.01"));
208 assert_eq!(p.round_currency(n("-2.005")), n("-2.01"));
209 assert_eq!(p.round_component(n("0.00005")), n("0.0001"));
210 assert_eq!(PricingPolicy::zero_decimal_currency().round_currency(n("2.5")), n("3"));
211 }
212}