Skip to main content

mako_emob/
session.rs

1//! Ladevorgänge, and how one becomes quarter-hour energies.
2//!
3//! # The quarter-hour grid is DST-safe by construction
4//!
5//! A [`Viertelstunde`] is an *instant* plus fifteen minutes of real time, not a
6//! wall-clock label. German local time is UTC+1 or UTC+2, both whole hours, so
7//! a UTC-aligned quarter hour is also aligned in Europe/Berlin — and the
8//! 92-slot and 100-slot days need no special case, because they are simply days
9//! with fewer or more instants in them. Nothing here counts „96".
10//!
11//! # Provenance is not metadata
12//!
13//! Two very different things can produce a session's energy, and the difference
14//! is visible in the result. A charge point that reports **clock-aligned meter
15//! values** every 900 s (OCPP `AlignedDataCtrlr` / `ClockAlignedDataInterval`)
16//! measures each quarter hour. A **CDR** reports one total for the whole
17//! session, and splitting it assumes constant power, which a tapering charge
18//! curve is not. The second is an estimate in the shape of a measurement, so
19//! [`Provenance`] rides on every value.
20
21use rust_decimal::Decimal;
22use serde::{Deserialize, Serialize};
23use time::{Date, Duration, OffsetDateTime};
24
25use metering::allocation::{AllocationBasis, AllocationPart, allocate};
26
27use crate::error::EmobError;
28use crate::ids::{SessionId, TokenRef, VirtualMaloId};
29
30/// The length of one quarter hour.
31pub const VIERTELSTUNDE: Duration = Duration::minutes(15);
32
33/// The most quarter hours one [`Ladevorgang`] may span — a calendar year.
34///
35/// A corruption guard, not a regulatory bound. [`Ladevorgang::viertelstunden`]
36/// walks the grid one slot at a time, so a backend that reports an `ende` in
37/// the year 9999 would allocate until the process dies. The longest real
38/// session is a few days; a year is three orders of magnitude past it and
39/// still small enough to hold.
40pub const MAX_SLOTS_JE_LADEVORGANG: u64 = 366 * 96;
41
42/// One quarter hour of the German market grid, named by the instant it starts.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
44pub struct Viertelstunde {
45    #[serde(with = "time::serde::rfc3339")]
46    start: OffsetDateTime,
47}
48
49impl Viertelstunde {
50    /// The quarter hour `at` falls in.
51    ///
52    /// Truncates toward the past, so an instant exactly on a boundary belongs
53    /// to the slot it opens.
54    ///
55    /// # Panics
56    ///
57    /// Never for a value obtained from an [`OffsetDateTime`]: truncating a
58    /// representable Unix timestamp toward the past stays representable.
59    #[must_use]
60    pub fn containing(at: OffsetDateTime) -> Self {
61        let secs = at.unix_timestamp();
62        let slot = secs.div_euclid(900) * 900;
63        Self {
64            start: OffsetDateTime::from_unix_timestamp(slot)
65                .expect("a truncated valid timestamp is valid"),
66        }
67    }
68
69    /// The instant the quarter hour opens.
70    #[must_use]
71    pub const fn start(self) -> OffsetDateTime {
72        self.start
73    }
74
75    /// The instant the quarter hour closes — the start of the next one.
76    #[must_use]
77    pub fn end(self) -> OffsetDateTime {
78        self.start + VIERTELSTUNDE
79    }
80
81    /// The next quarter hour.
82    #[must_use]
83    pub fn next(self) -> Self {
84        Self { start: self.end() }
85    }
86
87    /// The Europe/Berlin calendar day this quarter hour is settled under.
88    #[must_use]
89    pub fn berlin_day(self) -> Date {
90        mako_fristen::berlin_date(self.start)
91    }
92
93    /// Seconds of this quarter hour that lie inside `[von, bis)`.
94    ///
95    /// Zero when they do not overlap; never negative.
96    #[must_use]
97    pub fn overlap_secs(self, von: OffsetDateTime, bis: OffsetDateTime) -> i64 {
98        let from = self.start.max(von);
99        let to = self.end().min(bis);
100        (to - from).whole_seconds().max(0)
101    }
102}
103
104/// Where a quarter-hour energy came from.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
107pub enum Provenance {
108    /// Clock-aligned meter values from the charge point, one per quarter hour.
109    ///
110    /// The only provenance that *measures* the slot. Preferred wherever the
111    /// station delivers it.
112    ClockAlignedMeterValues,
113    /// One CDR total, split across the slots it spans in proportion to time.
114    ///
115    /// An estimate: it assumes constant power across the session, which
116    /// tapering charge curves violate. Correct in aggregate over a session,
117    /// wrong within it — and the error lands on whichever supplier held the
118    /// slot boundary.
119    CdrProRata,
120    /// A station-local log, neither clock-aligned nor a settled CDR.
121    DeviceLog,
122}
123
124impl Provenance {
125    /// `true` when the value measures its quarter hour rather than estimating it.
126    #[must_use]
127    pub const fn ist_gemessen(self) -> bool {
128        matches!(self, Self::ClockAlignedMeterValues)
129    }
130}
131
132/// One quarter hour's worth of one session's energy.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SlotEnergie {
135    /// The quarter hour.
136    pub slot: Viertelstunde,
137    /// Energy in kWh, always non-negative.
138    pub kwh: Decimal,
139    /// How this value was arrived at.
140    pub provenance: Provenance,
141}
142
143/// A charging session, as the CPO backend reports it.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Ladevorgang {
146    /// The backend's own id, for deduplicating a late CDR.
147    pub id: SessionId,
148    /// Which virtual Marktlokation — and so which supplier — this belongs to.
149    pub virtual_malo: VirtualMaloId,
150    /// The contract token, as an opaque keyed hash. `None` for an
151    /// unauthenticated draw.
152    pub token: Option<TokenRef>,
153    /// When charging began.
154    #[serde(with = "time::serde::rfc3339")]
155    pub beginn: OffsetDateTime,
156    /// When charging ended.
157    #[serde(with = "time::serde::rfc3339")]
158    pub ende: OffsetDateTime,
159    /// Total energy drawn, in kWh.
160    pub energie_kwh: Decimal,
161    /// Where [`Self::energie_kwh`] came from.
162    pub provenance: Provenance,
163}
164
165/// A session split across the quarter hours it spans.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct SessionSplit {
168    /// One entry per quarter hour the session touched, in time order.
169    pub slots: Vec<SlotEnergie>,
170    /// Energy the split could not place, in kWh.
171    ///
172    /// **Not an error and not silent.** A proportional split cuts each share to
173    /// six decimal places toward zero (`metering::allocation::ALLOCATION_DP`),
174    /// so a session spanning many slots can leave a millionth of a kWh
175    /// unplaced. That energy was really drawn, so it does not vanish: it stays
176    /// out of every supplier's Bilanzkreis and lands in the Deltamenge, which
177    /// Anlage 6 §IV.2 books to the LPB's own Bilanzkreis at its cost. Reported
178    /// here so an operator can see the magnitude rather than discover it in a
179    /// yearly reconciliation.
180    pub nicht_zugeordnet_kwh: Decimal,
181}
182
183impl Ladevorgang {
184    /// How many quarter hours this session touches.
185    ///
186    /// Computed rather than counted, so an absurd `ende` is caught before any
187    /// allocation is made.
188    #[must_use]
189    fn slot_count(&self) -> u64 {
190        if self.ende <= self.beginn {
191            return 0;
192        }
193        let erster = Viertelstunde::containing(self.beginn).start();
194        // `ende` is exclusive, so the last slot is the one containing the
195        // instant just before it — round the span up instead.
196        let spanne = (self.ende - erster).whole_seconds().max(0);
197        u64::try_from(spanne.div_euclid(900) + i64::from(spanne.rem_euclid(900) != 0))
198            .unwrap_or(u64::MAX)
199    }
200
201    /// The quarter hours this session touches, in time order.
202    ///
203    /// Empty when the session has no duration.
204    ///
205    /// # Errors
206    ///
207    /// [`EmobError::LadevorgangZuLang`] when the session spans more than
208    /// [`MAX_SLOTS_JE_LADEVORGANG`] quarter hours.
209    pub fn viertelstunden(&self) -> Result<Vec<Viertelstunde>, EmobError> {
210        let count = self.slot_count();
211        if count > MAX_SLOTS_JE_LADEVORGANG {
212            return Err(EmobError::LadevorgangZuLang {
213                id: self.id.to_string(),
214                slots: count,
215                max: MAX_SLOTS_JE_LADEVORGANG,
216            });
217        }
218        let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
219        let mut slot = Viertelstunde::containing(self.beginn);
220        for _ in 0..count {
221            out.push(slot);
222            slot = slot.next();
223        }
224        Ok(out)
225    }
226
227    /// Split [`Self::energie_kwh`] across those quarter hours, pro rata by
228    /// overlap.
229    ///
230    /// The arithmetic is `metering::allocation::allocate` with the overlap in
231    /// seconds as the weight, which is what guarantees
232    /// `Σ slots + nicht_zugeordnet = energie_kwh` **exactly** rather than
233    /// approximately.
234    ///
235    /// A session already delivered as clock-aligned meter values should not go
236    /// through here at all — it is already per-slot. Passing one anyway keeps
237    /// its [`Provenance`], because a value that was measured stays measured
238    /// even if it is re-split.
239    ///
240    /// # Errors
241    ///
242    /// [`EmobError::Allocation`] when the energy is negative — an Einspeisung
243    /// belongs in its own direction, not in a negative Bezug — and
244    /// [`EmobError::LadevorgangZuLang`] when the session spans an implausible
245    /// number of quarter hours.
246    pub fn in_viertelstunden(&self) -> Result<SessionSplit, EmobError> {
247        if self.energie_kwh < Decimal::ZERO {
248            return Err(EmobError::Allocation(format!(
249                "session {} carries negative energy {}; model Einspeisung as its own \
250                 Richtung rather than as a negative Bezug",
251                self.id, self.energie_kwh
252            )));
253        }
254        let slots = self.viertelstunden()?;
255        if slots.is_empty() {
256            return Ok(SessionSplit {
257                slots: Vec::new(),
258                nicht_zugeordnet_kwh: self.energie_kwh,
259            });
260        }
261
262        let parts: Vec<AllocationPart> = slots
263            .iter()
264            .map(|s| {
265                AllocationPart::new(
266                    s.start().unix_timestamp().to_string(),
267                    Decimal::from(s.overlap_secs(self.beginn, self.ende)),
268                )
269            })
270            .collect();
271
272        let row = allocate(self.energie_kwh, parts, AllocationBasis::Proportional)?;
273
274        let placed = slots
275            .into_iter()
276            .zip(row.parts.iter())
277            .map(|(slot, part)| SlotEnergie {
278                slot,
279                kwh: part.allocated,
280                provenance: self.provenance,
281            })
282            .collect();
283
284        Ok(SessionSplit {
285            slots: placed,
286            nicht_zugeordnet_kwh: row.residual,
287        })
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use rust_decimal::dec;
295    use time::macros::datetime;
296
297    fn session(beginn: OffsetDateTime, ende: OffsetDateTime, kwh: Decimal) -> Ladevorgang {
298        Ladevorgang {
299            id: SessionId::new("s1"),
300            virtual_malo: VirtualMaloId::new("veh-1").unwrap(),
301            token: None,
302            beginn,
303            ende,
304            energie_kwh: kwh,
305            provenance: Provenance::CdrProRata,
306        }
307    }
308
309    #[test]
310    fn a_slot_is_aligned_to_the_quarter_hour() {
311        let v = Viertelstunde::containing(datetime!(2026-11-03 08:07:31 UTC));
312        assert_eq!(v.start(), datetime!(2026-11-03 08:00:00 UTC));
313        assert_eq!(v.end(), datetime!(2026-11-03 08:15:00 UTC));
314    }
315
316    #[test]
317    fn an_instant_on_the_boundary_opens_its_slot() {
318        let v = Viertelstunde::containing(datetime!(2026-11-03 08:15:00 UTC));
319        assert_eq!(v.start(), datetime!(2026-11-03 08:15:00 UTC));
320    }
321
322    /// Alignment must survive the pre-1970 sign, which integer division does not.
323    #[test]
324    fn alignment_uses_euclidean_division() {
325        let v = Viertelstunde::containing(datetime!(1969-12-31 23:52:00 UTC));
326        assert_eq!(v.start(), datetime!(1969-12-31 23:45:00 UTC));
327    }
328
329    #[test]
330    fn a_session_inside_one_slot_stays_whole() {
331        let s = session(
332            datetime!(2026-11-03 08:02:00 UTC),
333            datetime!(2026-11-03 08:10:00 UTC),
334            dec!(4),
335        );
336        let split = s.in_viertelstunden().unwrap();
337        assert_eq!(split.slots.len(), 1);
338        assert_eq!(split.slots[0].kwh, dec!(4));
339        assert_eq!(split.nicht_zugeordnet_kwh, Decimal::ZERO);
340    }
341
342    #[test]
343    fn a_session_spanning_two_slots_splits_by_overlap() {
344        // 08:00–08:30 exactly: two full slots, half each.
345        let s = session(
346            datetime!(2026-11-03 08:00:00 UTC),
347            datetime!(2026-11-03 08:30:00 UTC),
348            dec!(10),
349        );
350        let split = s.in_viertelstunden().unwrap();
351        assert_eq!(split.slots.len(), 2);
352        assert_eq!(split.slots[0].kwh, dec!(5));
353        assert_eq!(split.slots[1].kwh, dec!(5));
354        assert_eq!(split.nicht_zugeordnet_kwh, Decimal::ZERO);
355    }
356
357    #[test]
358    fn a_partial_first_slot_gets_its_share() {
359        // 08:10–08:20: 5 min in the 08:00 slot, 5 min in the 08:15 slot.
360        let s = session(
361            datetime!(2026-11-03 08:10:00 UTC),
362            datetime!(2026-11-03 08:20:00 UTC),
363            dec!(2),
364        );
365        let split = s.in_viertelstunden().unwrap();
366        assert_eq!(split.slots.len(), 2);
367        assert_eq!(split.slots[0].kwh, dec!(1));
368        assert_eq!(split.slots[1].kwh, dec!(1));
369    }
370
371    /// The identity is the point: nothing is created and nothing disappears.
372    #[test]
373    fn the_split_conserves_energy_exactly() {
374        // A number that does not divide evenly by three slots.
375        let s = session(
376            datetime!(2026-11-03 08:00:00 UTC),
377            datetime!(2026-11-03 08:45:00 UTC),
378            dec!(10),
379        );
380        let split = s.in_viertelstunden().unwrap();
381        let sum: Decimal = split.slots.iter().map(|s| s.kwh).sum();
382        assert_eq!(sum + split.nicht_zugeordnet_kwh, dec!(10));
383        assert!(split.nicht_zugeordnet_kwh >= Decimal::ZERO);
384    }
385
386    /// A long session across a whole day still conserves.
387    #[test]
388    fn a_long_session_conserves_too() {
389        let s = session(
390            datetime!(2026-11-03 00:00:00 UTC),
391            datetime!(2026-11-04 00:00:00 UTC),
392            dec!(77.77),
393        );
394        let split = s.in_viertelstunden().unwrap();
395        assert_eq!(split.slots.len(), 96);
396        let sum: Decimal = split.slots.iter().map(|s| s.kwh).sum();
397        assert_eq!(sum + split.nicht_zugeordnet_kwh, dec!(77.77));
398    }
399
400    /// The clocks change and the day is 23 or 25 hours long; nothing special
401    /// happens, because a quarter hour is fifteen minutes of real time.
402    #[test]
403    fn the_grid_needs_no_dst_special_case() {
404        // Europe/Berlin spring-forward 2027-03-28: 02:00 local jumps to 03:00,
405        // which is 01:00 UTC. A session across it is still 15-minute slots.
406        let s = session(
407            datetime!(2027-03-28 00:30:00 UTC),
408            datetime!(2027-03-28 01:30:00 UTC),
409            dec!(8),
410        );
411        let split = s.in_viertelstunden().unwrap();
412        assert_eq!(split.slots.len(), 4);
413        assert_eq!(split.slots.iter().map(|s| s.kwh).sum::<Decimal>(), dec!(8));
414    }
415
416    #[test]
417    fn a_zero_length_session_places_nothing() {
418        let s = session(
419            datetime!(2026-11-03 08:00:00 UTC),
420            datetime!(2026-11-03 08:00:00 UTC),
421            dec!(3),
422        );
423        let split = s.in_viertelstunden().unwrap();
424        assert!(split.slots.is_empty());
425        assert_eq!(split.nicht_zugeordnet_kwh, dec!(3));
426    }
427
428    /// A backend that reports the wrong century must not allocate until the
429    /// process dies.
430    #[test]
431    fn an_implausibly_long_session_is_refused_before_any_allocation() {
432        let s = session(
433            datetime!(2026-11-03 08:00:00 UTC),
434            datetime!(2126-11-03 08:00:00 UTC),
435            dec!(8),
436        );
437        let e = s.in_viertelstunden().unwrap_err();
438        assert!(matches!(e, EmobError::LadevorgangZuLang { .. }), "{e:?}");
439        assert!(s.viertelstunden().is_err());
440    }
441
442    /// The bound is generous enough that a real long session is unaffected.
443    #[test]
444    fn a_week_long_session_is_still_allowed() {
445        let s = session(
446            datetime!(2026-11-03 08:00:00 UTC),
447            datetime!(2026-11-10 08:00:00 UTC),
448            dec!(500),
449        );
450        assert_eq!(s.viertelstunden().unwrap().len(), 7 * 96);
451    }
452
453    #[test]
454    fn negative_energy_is_refused_rather_than_split() {
455        let s = session(
456            datetime!(2026-11-03 08:00:00 UTC),
457            datetime!(2026-11-03 08:30:00 UTC),
458            dec!(-5),
459        );
460        assert!(s.in_viertelstunden().is_err());
461    }
462
463    #[test]
464    fn provenance_rides_on_every_slot() {
465        let mut s = session(
466            datetime!(2026-11-03 08:00:00 UTC),
467            datetime!(2026-11-03 08:30:00 UTC),
468            dec!(6),
469        );
470        s.provenance = Provenance::ClockAlignedMeterValues;
471        let split = s.in_viertelstunden().unwrap();
472        assert!(
473            split
474                .slots
475                .iter()
476                .all(|x| x.provenance == Provenance::ClockAlignedMeterValues)
477        );
478        assert!(Provenance::ClockAlignedMeterValues.ist_gemessen());
479        assert!(!Provenance::CdrProRata.ist_gemessen());
480    }
481}