use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use metering::allocation::{AllocationBasis, AllocationPart, allocate};
use crate::error::EmobError;
use crate::ids::VirtualMaloId;
use crate::session::Viertelstunde;
pub use mako_mabis::Datenstatus;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Richtung {
Bezug,
Einspeisung,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MaloKind {
Vehicle,
Device,
Household,
Betriebsstrom,
Residual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Anspruch {
pub malo: VirtualMaloId,
pub kind: MaloKind,
pub kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Zuordnung {
pub malo: VirtualMaloId,
pub kind: MaloKind,
pub anspruch_kwh: Decimal,
pub kwh: Decimal,
}
impl Zuordnung {
#[must_use]
pub fn gekuerzt(&self) -> bool {
self.kwh < self.anspruch_kwh
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ueberdeckung {
pub anspruch_kwh: Decimal,
pub ngz_kwh: Decimal,
}
impl Ueberdeckung {
#[must_use]
pub fn ueberhang_kwh(&self) -> Decimal {
self.anspruch_kwh - self.ngz_kwh
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConservationProof {
pub ngz_kwh: Decimal,
pub zugeordnet_kwh: Decimal,
pub delta_kwh: Decimal,
}
impl ConservationProof {
#[must_use]
pub fn haelt(&self) -> bool {
self.zugeordnet_kwh + self.delta_kwh == self.ngz_kwh
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuarterHourAllocation {
pub slot: Viertelstunde,
pub richtung: Richtung,
pub ngz_kwh: Decimal,
pub zuordnungen: Vec<Zuordnung>,
pub delta_kwh: Decimal,
pub ueberdeckung: Option<Ueberdeckung>,
pub proof: ConservationProof,
}
impl QuarterHourAllocation {
pub fn allocate(
slot: Viertelstunde,
richtung: Richtung,
ngz_kwh: Decimal,
ansprueche: &[Anspruch],
) -> Result<Self, EmobError> {
if ngz_kwh < Decimal::ZERO {
return Err(EmobError::Allocation(format!(
"the Netzgangzeitreihe value {ngz_kwh} is negative; settle a reverse flow as \
Richtung::Einspeisung"
)));
}
if let Some(bad) = ansprueche.iter().find(|a| a.kwh < Decimal::ZERO) {
return Err(EmobError::Allocation(format!(
"virtual Marktlokation {} claims negative energy {}",
bad.malo, bad.kwh
)));
}
let mut gesehen = std::collections::BTreeSet::new();
if let Some(dup) = ansprueche.iter().find(|a| !gesehen.insert(&a.malo)) {
return Err(EmobError::DoppelterAnspruch {
malo: dup.malo.to_string(),
});
}
let anspruch_sum: Decimal = ansprueche.iter().map(|a| a.kwh).sum();
let parts: Vec<AllocationPart> = ansprueche
.iter()
.map(|a| AllocationPart::new(a.malo.as_str(), a.kwh).capped_at(a.kwh))
.collect();
let row = allocate(ngz_kwh, parts, AllocationBasis::Proportional)?;
let zuordnungen: Vec<Zuordnung> = ansprueche
.iter()
.zip(row.parts.iter())
.map(|(a, p)| Zuordnung {
malo: a.malo.clone(),
kind: a.kind,
anspruch_kwh: a.kwh,
kwh: p.allocated,
})
.collect();
let zugeordnet_kwh: Decimal = zuordnungen.iter().map(|z| z.kwh).sum();
let delta_kwh = row.residual;
let proof = ConservationProof {
ngz_kwh,
zugeordnet_kwh,
delta_kwh,
};
if !proof.haelt() {
return Err(EmobError::ErhaltungVerletzt {
slot: slot.start().to_string(),
ngz: ngz_kwh,
summe: zugeordnet_kwh,
delta: delta_kwh,
});
}
let ueberdeckung = (anspruch_sum > ngz_kwh).then_some(Ueberdeckung {
anspruch_kwh: anspruch_sum,
ngz_kwh,
});
Ok(Self {
slot,
richtung,
ngz_kwh,
zuordnungen,
delta_kwh,
ueberdeckung,
proof,
})
}
#[must_use]
pub fn kwh_of_kind(&self, kind: MaloKind) -> Decimal {
self.zuordnungen
.iter()
.filter(|z| z.kind == kind)
.map(|z| z.kwh)
.sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllocationVersion {
pub erstellungszeitpunkt: OffsetDateTime,
pub datenstatus: Datenstatus,
pub rows: Vec<QuarterHourAllocation>,
}
impl AllocationVersion {
#[must_use]
pub fn delta_kwh(&self) -> Decimal {
self.rows.iter().map(|r| r.delta_kwh).sum()
}
pub fn ueberdeckungen(&self) -> impl Iterator<Item = &QuarterHourAllocation> {
self.rows.iter().filter(|r| r.ueberdeckung.is_some())
}
#[must_use]
pub fn erhaltung_haelt(&self) -> bool {
self.rows.iter().all(|r| r.proof.haelt())
}
#[must_use]
pub fn ist_final(&self) -> bool {
self.datenstatus.ist_abgerechnet()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Versionsreihe {
monatsende: time::Date,
versionen: Vec<AllocationVersion>,
}
impl Versionsreihe {
#[must_use]
pub fn fuer(tag: time::Date) -> Self {
Self {
monatsende: mako_mabis::Bilanzierungsmonat::enthaltend(tag).monatsende(),
versionen: Vec::new(),
}
}
#[must_use]
pub fn monat(&self) -> mako_mabis::Bilanzierungsmonat {
mako_mabis::Bilanzierungsmonat::new(self.monatsende)
}
#[must_use]
pub fn korrekturfrist(&self) -> time::Date {
crate::fristen::korrekturfrist(self.monat())
}
pub fn iter(&self) -> impl Iterator<Item = &AllocationVersion> {
self.versionen.iter()
}
#[must_use]
pub fn aktuell(&self) -> Option<&AllocationVersion> {
self.versionen.last()
}
pub fn einreichen(
&mut self,
version: AllocationVersion,
eingang: time::Date,
) -> Result<(), EmobError> {
if let Some(letzte) = self.versionen.last() {
if letzte.ist_final() {
return Err(EmobError::VersionIstFinal {
erstellungszeitpunkt: letzte.erstellungszeitpunkt.to_string(),
});
}
if version.erstellungszeitpunkt <= letzte.erstellungszeitpunkt {
return Err(EmobError::Allocation(format!(
"Erstellungszeitpunkt {} does not advance on the filed {}; MaBiS keys \
versions on it",
version.erstellungszeitpunkt, letzte.erstellungszeitpunkt
)));
}
}
let frist = self.korrekturfrist();
if eingang > frist {
return Err(EmobError::KorrekturfristAbgelaufen {
monat: format!(
"{}-{:02}",
self.monatsende.year(),
u8::from(self.monatsende.month())
),
frist,
eingang,
});
}
if !version.erhaltung_haelt() {
return Err(EmobError::Allocation(
"a version whose conservation identity fails cannot be filed (Anlage 6 §IV.1)"
.to_owned(),
));
}
self.versionen.push(version);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::datetime;
fn slot() -> Viertelstunde {
Viertelstunde::containing(datetime!(2026-11-03 08:00:00 UTC))
}
fn a(id: &str, kind: MaloKind, kwh: Decimal) -> Anspruch {
Anspruch {
malo: VirtualMaloId::new(id).unwrap(),
kind,
kwh,
}
}
#[test]
fn an_underclaimed_quarter_hour_puts_the_rest_in_the_delta() {
let r = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(12),
&[
a("veh-1", MaloKind::Vehicle, dec!(6)),
a("veh-2", MaloKind::Vehicle, dec!(3)),
],
)
.unwrap();
assert_eq!(r.zuordnungen[0].kwh, dec!(6));
assert_eq!(r.zuordnungen[1].kwh, dec!(3));
assert_eq!(r.delta_kwh, dec!(3));
assert!(r.ueberdeckung.is_none());
assert!(r.proof.haelt());
}
#[test]
fn an_overclaimed_quarter_hour_cuts_back_proportionally_and_says_so() {
let r = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[
a("veh-1", MaloKind::Vehicle, dec!(10)),
a("veh-2", MaloKind::Vehicle, dec!(10)),
],
)
.unwrap();
assert_eq!(r.zuordnungen[0].kwh, dec!(5));
assert_eq!(r.zuordnungen[1].kwh, dec!(5));
assert_eq!(r.delta_kwh, Decimal::ZERO);
let u = r.ueberdeckung.expect("recorded, never silent");
assert_eq!(u.ueberhang_kwh(), dec!(10));
assert!(r.zuordnungen.iter().all(Zuordnung::gekuerzt));
assert!(r.proof.haelt());
}
#[test]
fn betriebsstrom_and_residual_are_ordinary_claims() {
let r = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[
a("veh-1", MaloKind::Vehicle, dec!(7)),
a("station-1", MaloKind::Betriebsstrom, dec!(1)),
a("residual", MaloKind::Residual, dec!(2)),
],
)
.unwrap();
assert_eq!(r.delta_kwh, Decimal::ZERO);
assert_eq!(r.kwh_of_kind(MaloKind::Betriebsstrom), dec!(1));
assert_eq!(r.kwh_of_kind(MaloKind::Residual), dec!(2));
assert_eq!(r.kwh_of_kind(MaloKind::Vehicle), dec!(7));
}
#[test]
fn no_claims_means_the_whole_slot_is_delta() {
let r = QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(4), &[]).unwrap();
assert_eq!(r.delta_kwh, dec!(4));
assert!(r.proof.haelt());
}
#[test]
fn a_zero_ngz_allocates_nothing() {
let r = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
Decimal::ZERO,
&[a("veh-1", MaloKind::Vehicle, dec!(5))],
)
.unwrap();
assert_eq!(r.zuordnungen[0].kwh, Decimal::ZERO);
assert_eq!(r.delta_kwh, Decimal::ZERO);
assert!(r.proof.haelt());
}
#[test]
fn conservation_survives_a_non_terminating_share() {
let r = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[
a("v1", MaloKind::Vehicle, dec!(10)),
a("v2", MaloKind::Vehicle, dec!(10)),
a("v3", MaloKind::Vehicle, dec!(10)),
],
)
.unwrap();
assert!(r.proof.haelt());
let sum: Decimal = r.zuordnungen.iter().map(|z| z.kwh).sum();
assert_eq!(sum + r.delta_kwh, dec!(10));
}
#[test]
fn a_marktlokation_may_not_claim_the_same_slot_twice() {
let e = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[
a("veh-1", MaloKind::Vehicle, dec!(4)),
a("veh-1", MaloKind::Vehicle, dec!(3)),
],
)
.unwrap_err();
assert!(matches!(e, EmobError::DoppelterAnspruch { .. }), "{e:?}");
}
#[test]
fn negative_inputs_are_refused() {
assert!(QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(-1), &[]).is_err());
assert!(
QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(1),
&[a("v1", MaloKind::Vehicle, dec!(-1))]
)
.is_err()
);
}
#[test]
fn the_two_directions_are_settled_apart() {
let bezug = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[a("v1", MaloKind::Vehicle, dec!(10))],
)
.unwrap();
let einspeisung = QuarterHourAllocation::allocate(
slot(),
Richtung::Einspeisung,
dec!(4),
&[a("v1", MaloKind::Vehicle, dec!(4))],
)
.unwrap();
assert_eq!(bezug.zuordnungen[0].kwh, dec!(10));
assert_eq!(einspeisung.zuordnungen[0].kwh, dec!(4));
assert_ne!(bezug.richtung, einspeisung.richtung);
}
#[test]
fn a_version_totals_its_delta_and_flags_its_overclaims() {
let good = QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(12),
&[a("v1", MaloKind::Vehicle, dec!(9))],
)
.unwrap();
let over = QuarterHourAllocation::allocate(
slot().next(),
Richtung::Bezug,
dec!(5),
&[a("v1", MaloKind::Vehicle, dec!(8))],
)
.unwrap();
let v = AllocationVersion {
erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
datenstatus: Datenstatus::Pruefdaten,
rows: vec![good, over],
};
assert_eq!(v.delta_kwh(), dec!(3));
assert_eq!(v.ueberdeckungen().count(), 1);
assert!(v.erhaltung_haelt());
assert!(!v.ist_final());
}
fn version(stamp: OffsetDateTime, status: Datenstatus) -> AllocationVersion {
AllocationVersion {
erstellungszeitpunkt: stamp,
datenstatus: status,
rows: vec![
QuarterHourAllocation::allocate(
slot(),
Richtung::Bezug,
dec!(10),
&[a("v1", MaloKind::Vehicle, dec!(6))],
)
.unwrap(),
],
}
}
fn tag(y: i32, m: u8, d: u8) -> time::Date {
time::Date::from_calendar_date(y, time::Month::try_from(m).unwrap(), d).unwrap()
}
#[test]
fn a_series_takes_corrections_until_the_month_settles() {
let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
assert_eq!(reihe.korrekturfrist(), tag(2027, 6, 30));
reihe
.einreichen(
version(datetime!(2026-12-05 09:00:00 UTC), Datenstatus::Pruefdaten),
tag(2026, 12, 5),
)
.unwrap();
reihe
.einreichen(
version(
datetime!(2027-01-08 09:00:00 UTC),
Datenstatus::Abrechnungsdaten,
),
tag(2027, 1, 8),
)
.unwrap();
assert_eq!(reihe.iter().count(), 2);
assert_eq!(
reihe.aktuell().unwrap().datenstatus,
Datenstatus::Abrechnungsdaten
);
}
#[test]
fn nothing_follows_an_abgerechnete_version() {
let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
reihe
.einreichen(
version(
datetime!(2027-01-08 09:00:00 UTC),
Datenstatus::AbgerechneteDaten,
),
tag(2027, 1, 8),
)
.unwrap();
let e = reihe
.einreichen(
version(datetime!(2027-02-08 09:00:00 UTC), Datenstatus::Pruefdaten),
tag(2027, 2, 8),
)
.unwrap_err();
assert!(matches!(e, EmobError::VersionIstFinal { .. }), "{e:?}");
}
#[test]
fn a_filing_past_month_seven_is_refused() {
let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
let e = reihe
.einreichen(
version(datetime!(2027-07-01 09:00:00 UTC), Datenstatus::Pruefdaten),
tag(2027, 7, 1),
)
.unwrap_err();
match e {
EmobError::KorrekturfristAbgelaufen { monat, frist, .. } => {
assert_eq!(monat, "2026-11");
assert_eq!(frist, tag(2027, 6, 30));
}
other => panic!("{other:?}"),
}
}
#[test]
fn the_erstellungszeitpunkt_has_to_advance() {
let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
let stamp = datetime!(2026-12-05 09:00:00 UTC);
reihe
.einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
.unwrap();
assert!(
reihe
.einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
.is_err()
);
}
#[test]
fn settled_versions_are_final() {
for status in [
Datenstatus::AbgerechneteDaten,
Datenstatus::AbgerechneteDatenKbka,
] {
let v = AllocationVersion {
erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
datenstatus: status,
rows: Vec::new(),
};
assert!(v.ist_final(), "{status:?}");
}
for status in [
Datenstatus::Pruefdaten,
Datenstatus::Abrechnungsdaten,
Datenstatus::AbrechnungsdatenKbka,
] {
let v = AllocationVersion {
erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
datenstatus: status,
rows: Vec::new(),
};
assert!(!v.ist_final(), "{status:?}");
}
}
}