1use 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
30pub const VIERTELSTUNDE: Duration = Duration::minutes(15);
32
33pub const MAX_SLOTS_JE_LADEVORGANG: u64 = 366 * 96;
41
42#[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 #[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 #[must_use]
71 pub const fn start(self) -> OffsetDateTime {
72 self.start
73 }
74
75 #[must_use]
77 pub fn end(self) -> OffsetDateTime {
78 self.start + VIERTELSTUNDE
79 }
80
81 #[must_use]
83 pub fn next(self) -> Self {
84 Self { start: self.end() }
85 }
86
87 #[must_use]
89 pub fn berlin_day(self) -> Date {
90 mako_fristen::berlin_date(self.start)
91 }
92
93 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
107pub enum Provenance {
108 ClockAlignedMeterValues,
113 CdrProRata,
120 DeviceLog,
122}
123
124impl Provenance {
125 #[must_use]
127 pub const fn ist_gemessen(self) -> bool {
128 matches!(self, Self::ClockAlignedMeterValues)
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134pub struct SlotEnergie {
135 pub slot: Viertelstunde,
137 pub kwh: Decimal,
139 pub provenance: Provenance,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Ladevorgang {
146 pub id: SessionId,
148 pub virtual_malo: VirtualMaloId,
150 pub token: Option<TokenRef>,
153 #[serde(with = "time::serde::rfc3339")]
155 pub beginn: OffsetDateTime,
156 #[serde(with = "time::serde::rfc3339")]
158 pub ende: OffsetDateTime,
159 pub energie_kwh: Decimal,
161 pub provenance: Provenance,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct SessionSplit {
168 pub slots: Vec<SlotEnergie>,
170 pub nicht_zugeordnet_kwh: Decimal,
181}
182
183impl Ladevorgang {
184 #[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 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 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 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 #[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 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 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 #[test]
373 fn the_split_conserves_energy_exactly() {
374 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 #[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 #[test]
403 fn the_grid_needs_no_dst_special_case() {
404 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 #[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 #[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}