use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use time::{Date, Duration, OffsetDateTime};
use metering::allocation::{AllocationBasis, AllocationPart, allocate};
use crate::error::EmobError;
use crate::ids::{SessionId, TokenRef, VirtualMaloId};
pub const VIERTELSTUNDE: Duration = Duration::minutes(15);
pub const MAX_SLOTS_JE_LADEVORGANG: u64 = 366 * 96;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Viertelstunde {
start: OffsetDateTime,
}
impl Viertelstunde {
#[must_use]
pub fn containing(at: OffsetDateTime) -> Self {
let secs = at.unix_timestamp();
let slot = secs.div_euclid(900) * 900;
Self {
start: OffsetDateTime::from_unix_timestamp(slot)
.expect("a truncated valid timestamp is valid"),
}
}
#[must_use]
pub const fn start(self) -> OffsetDateTime {
self.start
}
#[must_use]
pub fn end(self) -> OffsetDateTime {
self.start + VIERTELSTUNDE
}
#[must_use]
pub fn next(self) -> Self {
Self { start: self.end() }
}
#[must_use]
pub fn berlin_day(self) -> Date {
mako_fristen::berlin_date(self.start)
}
#[must_use]
pub fn overlap_secs(self, von: OffsetDateTime, bis: OffsetDateTime) -> i64 {
let from = self.start.max(von);
let to = self.end().min(bis);
(to - from).whole_seconds().max(0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Provenance {
ClockAlignedMeterValues,
CdrProRata,
DeviceLog,
}
impl Provenance {
#[must_use]
pub const fn ist_gemessen(self) -> bool {
matches!(self, Self::ClockAlignedMeterValues)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SlotEnergie {
pub slot: Viertelstunde,
pub kwh: Decimal,
pub provenance: Provenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ladevorgang {
pub id: SessionId,
pub virtual_malo: VirtualMaloId,
pub token: Option<TokenRef>,
pub beginn: OffsetDateTime,
pub ende: OffsetDateTime,
pub energie_kwh: Decimal,
pub provenance: Provenance,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionSplit {
pub slots: Vec<SlotEnergie>,
pub nicht_zugeordnet_kwh: Decimal,
}
impl Ladevorgang {
#[must_use]
fn slot_count(&self) -> u64 {
if self.ende <= self.beginn {
return 0;
}
let erster = Viertelstunde::containing(self.beginn).start();
let spanne = (self.ende - erster).whole_seconds().max(0);
u64::try_from(spanne.div_euclid(900) + i64::from(spanne.rem_euclid(900) != 0))
.unwrap_or(u64::MAX)
}
pub fn viertelstunden(&self) -> Result<Vec<Viertelstunde>, EmobError> {
let count = self.slot_count();
if count > MAX_SLOTS_JE_LADEVORGANG {
return Err(EmobError::LadevorgangZuLang {
id: self.id.to_string(),
slots: count,
max: MAX_SLOTS_JE_LADEVORGANG,
});
}
let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0));
let mut slot = Viertelstunde::containing(self.beginn);
for _ in 0..count {
out.push(slot);
slot = slot.next();
}
Ok(out)
}
pub fn in_viertelstunden(&self) -> Result<SessionSplit, EmobError> {
if self.energie_kwh < Decimal::ZERO {
return Err(EmobError::Allocation(format!(
"session {} carries negative energy {}; model Einspeisung as its own \
Richtung rather than as a negative Bezug",
self.id, self.energie_kwh
)));
}
let slots = self.viertelstunden()?;
if slots.is_empty() {
return Ok(SessionSplit {
slots: Vec::new(),
nicht_zugeordnet_kwh: self.energie_kwh,
});
}
let parts: Vec<AllocationPart> = slots
.iter()
.map(|s| {
AllocationPart::new(
s.start().unix_timestamp().to_string(),
Decimal::from(s.overlap_secs(self.beginn, self.ende)),
)
})
.collect();
let row = allocate(self.energie_kwh, parts, AllocationBasis::Proportional)?;
let placed = slots
.into_iter()
.zip(row.parts.iter())
.map(|(slot, part)| SlotEnergie {
slot,
kwh: part.allocated,
provenance: self.provenance,
})
.collect();
Ok(SessionSplit {
slots: placed,
nicht_zugeordnet_kwh: row.residual,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::datetime;
fn session(beginn: OffsetDateTime, ende: OffsetDateTime, kwh: Decimal) -> Ladevorgang {
Ladevorgang {
id: SessionId::new("s1"),
virtual_malo: VirtualMaloId::new("veh-1").unwrap(),
token: None,
beginn,
ende,
energie_kwh: kwh,
provenance: Provenance::CdrProRata,
}
}
#[test]
fn a_slot_is_aligned_to_the_quarter_hour() {
let v = Viertelstunde::containing(datetime!(2026-11-03 08:07:31 UTC));
assert_eq!(v.start(), datetime!(2026-11-03 08:00:00 UTC));
assert_eq!(v.end(), datetime!(2026-11-03 08:15:00 UTC));
}
#[test]
fn an_instant_on_the_boundary_opens_its_slot() {
let v = Viertelstunde::containing(datetime!(2026-11-03 08:15:00 UTC));
assert_eq!(v.start(), datetime!(2026-11-03 08:15:00 UTC));
}
#[test]
fn alignment_uses_euclidean_division() {
let v = Viertelstunde::containing(datetime!(1969-12-31 23:52:00 UTC));
assert_eq!(v.start(), datetime!(1969-12-31 23:45:00 UTC));
}
#[test]
fn a_session_inside_one_slot_stays_whole() {
let s = session(
datetime!(2026-11-03 08:02:00 UTC),
datetime!(2026-11-03 08:10:00 UTC),
dec!(4),
);
let split = s.in_viertelstunden().unwrap();
assert_eq!(split.slots.len(), 1);
assert_eq!(split.slots[0].kwh, dec!(4));
assert_eq!(split.nicht_zugeordnet_kwh, Decimal::ZERO);
}
#[test]
fn a_session_spanning_two_slots_splits_by_overlap() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-03 08:30:00 UTC),
dec!(10),
);
let split = s.in_viertelstunden().unwrap();
assert_eq!(split.slots.len(), 2);
assert_eq!(split.slots[0].kwh, dec!(5));
assert_eq!(split.slots[1].kwh, dec!(5));
assert_eq!(split.nicht_zugeordnet_kwh, Decimal::ZERO);
}
#[test]
fn a_partial_first_slot_gets_its_share() {
let s = session(
datetime!(2026-11-03 08:10:00 UTC),
datetime!(2026-11-03 08:20:00 UTC),
dec!(2),
);
let split = s.in_viertelstunden().unwrap();
assert_eq!(split.slots.len(), 2);
assert_eq!(split.slots[0].kwh, dec!(1));
assert_eq!(split.slots[1].kwh, dec!(1));
}
#[test]
fn the_split_conserves_energy_exactly() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-03 08:45:00 UTC),
dec!(10),
);
let split = s.in_viertelstunden().unwrap();
let sum: Decimal = split.slots.iter().map(|s| s.kwh).sum();
assert_eq!(sum + split.nicht_zugeordnet_kwh, dec!(10));
assert!(split.nicht_zugeordnet_kwh >= Decimal::ZERO);
}
#[test]
fn a_long_session_conserves_too() {
let s = session(
datetime!(2026-11-03 00:00:00 UTC),
datetime!(2026-11-04 00:00:00 UTC),
dec!(77.77),
);
let split = s.in_viertelstunden().unwrap();
assert_eq!(split.slots.len(), 96);
let sum: Decimal = split.slots.iter().map(|s| s.kwh).sum();
assert_eq!(sum + split.nicht_zugeordnet_kwh, dec!(77.77));
}
#[test]
fn the_grid_needs_no_dst_special_case() {
let s = session(
datetime!(2027-03-28 00:30:00 UTC),
datetime!(2027-03-28 01:30:00 UTC),
dec!(8),
);
let split = s.in_viertelstunden().unwrap();
assert_eq!(split.slots.len(), 4);
assert_eq!(split.slots.iter().map(|s| s.kwh).sum::<Decimal>(), dec!(8));
}
#[test]
fn a_zero_length_session_places_nothing() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-03 08:00:00 UTC),
dec!(3),
);
let split = s.in_viertelstunden().unwrap();
assert!(split.slots.is_empty());
assert_eq!(split.nicht_zugeordnet_kwh, dec!(3));
}
#[test]
fn an_implausibly_long_session_is_refused_before_any_allocation() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2126-11-03 08:00:00 UTC),
dec!(8),
);
let e = s.in_viertelstunden().unwrap_err();
assert!(matches!(e, EmobError::LadevorgangZuLang { .. }), "{e:?}");
assert!(s.viertelstunden().is_err());
}
#[test]
fn a_week_long_session_is_still_allowed() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-10 08:00:00 UTC),
dec!(500),
);
assert_eq!(s.viertelstunden().unwrap().len(), 7 * 96);
}
#[test]
fn negative_energy_is_refused_rather_than_split() {
let s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-03 08:30:00 UTC),
dec!(-5),
);
assert!(s.in_viertelstunden().is_err());
}
#[test]
fn provenance_rides_on_every_slot() {
let mut s = session(
datetime!(2026-11-03 08:00:00 UTC),
datetime!(2026-11-03 08:30:00 UTC),
dec!(6),
);
s.provenance = Provenance::ClockAlignedMeterValues;
let split = s.in_viertelstunden().unwrap();
assert!(
split
.slots
.iter()
.all(|x| x.provenance == Provenance::ClockAlignedMeterValues)
);
assert!(Provenance::ClockAlignedMeterValues.ist_gemessen());
assert!(!Provenance::CdrProRata.ist_gemessen());
}
}