Skip to main content

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}