1use rust_decimal::Decimal;
41use serde::{Deserialize, Serialize};
42use time::OffsetDateTime;
43
44use metering::allocation::{AllocationBasis, AllocationPart, allocate};
45
46use crate::error::EmobError;
47use crate::ids::VirtualMaloId;
48use crate::session::Viertelstunde;
49
50pub use mako_mabis::Datenstatus;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
55pub enum Richtung {
56 Bezug,
58 Einspeisung,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
75pub enum MaloKind {
76 Vehicle,
78 Device,
81 Household,
83 Betriebsstrom,
87 Residual,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Anspruch {
97 pub malo: VirtualMaloId,
99 pub kind: MaloKind,
101 pub kwh: Decimal,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Zuordnung {
108 pub malo: VirtualMaloId,
110 pub kind: MaloKind,
112 pub anspruch_kwh: Decimal,
114 pub kwh: Decimal,
116}
117
118impl Zuordnung {
119 #[must_use]
122 pub fn gekuerzt(&self) -> bool {
123 self.kwh < self.anspruch_kwh
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Ueberdeckung {
130 pub anspruch_kwh: Decimal,
132 pub ngz_kwh: Decimal,
134}
135
136impl Ueberdeckung {
137 #[must_use]
139 pub fn ueberhang_kwh(&self) -> Decimal {
140 self.anspruch_kwh - self.ngz_kwh
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct ConservationProof {
150 pub ngz_kwh: Decimal,
152 pub zugeordnet_kwh: Decimal,
154 pub delta_kwh: Decimal,
156}
157
158impl ConservationProof {
159 #[must_use]
161 pub fn haelt(&self) -> bool {
162 self.zugeordnet_kwh + self.delta_kwh == self.ngz_kwh
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct QuarterHourAllocation {
169 pub slot: Viertelstunde,
171 pub richtung: Richtung,
173 pub ngz_kwh: Decimal,
175 pub zuordnungen: Vec<Zuordnung>,
177 pub delta_kwh: Decimal,
179 pub ueberdeckung: Option<Ueberdeckung>,
181 pub proof: ConservationProof,
183}
184
185impl QuarterHourAllocation {
186 pub fn allocate(
197 slot: Viertelstunde,
198 richtung: Richtung,
199 ngz_kwh: Decimal,
200 ansprueche: &[Anspruch],
201 ) -> Result<Self, EmobError> {
202 if ngz_kwh < Decimal::ZERO {
203 return Err(EmobError::Allocation(format!(
204 "the Netzgangzeitreihe value {ngz_kwh} is negative; settle a reverse flow as \
205 Richtung::Einspeisung"
206 )));
207 }
208 if let Some(bad) = ansprueche.iter().find(|a| a.kwh < Decimal::ZERO) {
209 return Err(EmobError::Allocation(format!(
210 "virtual Marktlokation {} claims negative energy {}",
211 bad.malo, bad.kwh
212 )));
213 }
214 let mut gesehen = std::collections::BTreeSet::new();
220 if let Some(dup) = ansprueche.iter().find(|a| !gesehen.insert(&a.malo)) {
221 return Err(EmobError::DoppelterAnspruch {
222 malo: dup.malo.to_string(),
223 });
224 }
225
226 let anspruch_sum: Decimal = ansprueche.iter().map(|a| a.kwh).sum();
227
228 let parts: Vec<AllocationPart> = ansprueche
229 .iter()
230 .map(|a| AllocationPart::new(a.malo.as_str(), a.kwh).capped_at(a.kwh))
231 .collect();
232
233 let row = allocate(ngz_kwh, parts, AllocationBasis::Proportional)?;
234
235 let zuordnungen: Vec<Zuordnung> = ansprueche
236 .iter()
237 .zip(row.parts.iter())
238 .map(|(a, p)| Zuordnung {
239 malo: a.malo.clone(),
240 kind: a.kind,
241 anspruch_kwh: a.kwh,
242 kwh: p.allocated,
243 })
244 .collect();
245
246 let zugeordnet_kwh: Decimal = zuordnungen.iter().map(|z| z.kwh).sum();
247 let delta_kwh = row.residual;
248
249 let proof = ConservationProof {
250 ngz_kwh,
251 zugeordnet_kwh,
252 delta_kwh,
253 };
254 if !proof.haelt() {
255 return Err(EmobError::ErhaltungVerletzt {
256 slot: slot.start().to_string(),
257 ngz: ngz_kwh,
258 summe: zugeordnet_kwh,
259 delta: delta_kwh,
260 });
261 }
262
263 let ueberdeckung = (anspruch_sum > ngz_kwh).then_some(Ueberdeckung {
264 anspruch_kwh: anspruch_sum,
265 ngz_kwh,
266 });
267
268 Ok(Self {
269 slot,
270 richtung,
271 ngz_kwh,
272 zuordnungen,
273 delta_kwh,
274 ueberdeckung,
275 proof,
276 })
277 }
278
279 #[must_use]
281 pub fn kwh_of_kind(&self, kind: MaloKind) -> Decimal {
282 self.zuordnungen
283 .iter()
284 .filter(|z| z.kind == kind)
285 .map(|z| z.kwh)
286 .sum()
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct AllocationVersion {
298 #[serde(with = "time::serde::rfc3339")]
300 pub erstellungszeitpunkt: OffsetDateTime,
301 pub datenstatus: Datenstatus,
303 pub rows: Vec<QuarterHourAllocation>,
305}
306
307impl AllocationVersion {
308 #[must_use]
312 pub fn delta_kwh(&self) -> Decimal {
313 self.rows.iter().map(|r| r.delta_kwh).sum()
314 }
315
316 pub fn ueberdeckungen(&self) -> impl Iterator<Item = &QuarterHourAllocation> {
318 self.rows.iter().filter(|r| r.ueberdeckung.is_some())
319 }
320
321 #[must_use]
323 pub fn erhaltung_haelt(&self) -> bool {
324 self.rows.iter().all(|r| r.proof.haelt())
325 }
326
327 #[must_use]
334 pub fn ist_final(&self) -> bool {
335 self.datenstatus.ist_abgerechnet()
336 }
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct Versionsreihe {
354 monatsende: time::Date,
358 versionen: Vec<AllocationVersion>,
359}
360
361impl Versionsreihe {
362 #[must_use]
364 pub fn fuer(tag: time::Date) -> Self {
365 Self {
366 monatsende: mako_mabis::Bilanzierungsmonat::enthaltend(tag).monatsende(),
367 versionen: Vec::new(),
368 }
369 }
370
371 #[must_use]
373 pub fn monat(&self) -> mako_mabis::Bilanzierungsmonat {
374 mako_mabis::Bilanzierungsmonat::new(self.monatsende)
375 }
376
377 #[must_use]
380 pub fn korrekturfrist(&self) -> time::Date {
381 crate::fristen::korrekturfrist(self.monat())
382 }
383
384 pub fn iter(&self) -> impl Iterator<Item = &AllocationVersion> {
386 self.versionen.iter()
387 }
388
389 #[must_use]
391 pub fn aktuell(&self) -> Option<&AllocationVersion> {
392 self.versionen.last()
393 }
394
395 pub fn einreichen(
406 &mut self,
407 version: AllocationVersion,
408 eingang: time::Date,
409 ) -> Result<(), EmobError> {
410 if let Some(letzte) = self.versionen.last() {
411 if letzte.ist_final() {
412 return Err(EmobError::VersionIstFinal {
413 erstellungszeitpunkt: letzte.erstellungszeitpunkt.to_string(),
414 });
415 }
416 if version.erstellungszeitpunkt <= letzte.erstellungszeitpunkt {
417 return Err(EmobError::Allocation(format!(
418 "Erstellungszeitpunkt {} does not advance on the filed {}; MaBiS keys \
419 versions on it",
420 version.erstellungszeitpunkt, letzte.erstellungszeitpunkt
421 )));
422 }
423 }
424 let frist = self.korrekturfrist();
425 if eingang > frist {
426 return Err(EmobError::KorrekturfristAbgelaufen {
427 monat: format!(
428 "{}-{:02}",
429 self.monatsende.year(),
430 u8::from(self.monatsende.month())
431 ),
432 frist,
433 eingang,
434 });
435 }
436 if !version.erhaltung_haelt() {
437 return Err(EmobError::Allocation(
438 "a version whose conservation identity fails cannot be filed (Anlage 6 §IV.1)"
439 .to_owned(),
440 ));
441 }
442 self.versionen.push(version);
443 Ok(())
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use rust_decimal::dec;
451 use time::macros::datetime;
452
453 fn slot() -> Viertelstunde {
454 Viertelstunde::containing(datetime!(2026-11-03 08:00:00 UTC))
455 }
456
457 fn a(id: &str, kind: MaloKind, kwh: Decimal) -> Anspruch {
458 Anspruch {
459 malo: VirtualMaloId::new(id).unwrap(),
460 kind,
461 kwh,
462 }
463 }
464
465 #[test]
466 fn an_underclaimed_quarter_hour_puts_the_rest_in_the_delta() {
467 let r = QuarterHourAllocation::allocate(
468 slot(),
469 Richtung::Bezug,
470 dec!(12),
471 &[
472 a("veh-1", MaloKind::Vehicle, dec!(6)),
473 a("veh-2", MaloKind::Vehicle, dec!(3)),
474 ],
475 )
476 .unwrap();
477 assert_eq!(r.zuordnungen[0].kwh, dec!(6));
478 assert_eq!(r.zuordnungen[1].kwh, dec!(3));
479 assert_eq!(r.delta_kwh, dec!(3));
480 assert!(r.ueberdeckung.is_none());
481 assert!(r.proof.haelt());
482 }
483
484 #[test]
485 fn an_overclaimed_quarter_hour_cuts_back_proportionally_and_says_so() {
486 let r = QuarterHourAllocation::allocate(
487 slot(),
488 Richtung::Bezug,
489 dec!(10),
490 &[
491 a("veh-1", MaloKind::Vehicle, dec!(10)),
492 a("veh-2", MaloKind::Vehicle, dec!(10)),
493 ],
494 )
495 .unwrap();
496 assert_eq!(r.zuordnungen[0].kwh, dec!(5));
497 assert_eq!(r.zuordnungen[1].kwh, dec!(5));
498 assert_eq!(r.delta_kwh, Decimal::ZERO);
499 let u = r.ueberdeckung.expect("recorded, never silent");
500 assert_eq!(u.ueberhang_kwh(), dec!(10));
501 assert!(r.zuordnungen.iter().all(Zuordnung::gekuerzt));
502 assert!(r.proof.haelt());
503 }
504
505 #[test]
507 fn betriebsstrom_and_residual_are_ordinary_claims() {
508 let r = QuarterHourAllocation::allocate(
509 slot(),
510 Richtung::Bezug,
511 dec!(10),
512 &[
513 a("veh-1", MaloKind::Vehicle, dec!(7)),
514 a("station-1", MaloKind::Betriebsstrom, dec!(1)),
515 a("residual", MaloKind::Residual, dec!(2)),
516 ],
517 )
518 .unwrap();
519 assert_eq!(r.delta_kwh, Decimal::ZERO);
520 assert_eq!(r.kwh_of_kind(MaloKind::Betriebsstrom), dec!(1));
521 assert_eq!(r.kwh_of_kind(MaloKind::Residual), dec!(2));
522 assert_eq!(r.kwh_of_kind(MaloKind::Vehicle), dec!(7));
523 }
524
525 #[test]
527 fn no_claims_means_the_whole_slot_is_delta() {
528 let r = QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(4), &[]).unwrap();
529 assert_eq!(r.delta_kwh, dec!(4));
530 assert!(r.proof.haelt());
531 }
532
533 #[test]
534 fn a_zero_ngz_allocates_nothing() {
535 let r = QuarterHourAllocation::allocate(
536 slot(),
537 Richtung::Bezug,
538 Decimal::ZERO,
539 &[a("veh-1", MaloKind::Vehicle, dec!(5))],
540 )
541 .unwrap();
542 assert_eq!(r.zuordnungen[0].kwh, Decimal::ZERO);
543 assert_eq!(r.delta_kwh, Decimal::ZERO);
544 assert!(r.proof.haelt());
545 }
546
547 #[test]
549 fn conservation_survives_a_non_terminating_share() {
550 let r = QuarterHourAllocation::allocate(
551 slot(),
552 Richtung::Bezug,
553 dec!(10),
554 &[
555 a("v1", MaloKind::Vehicle, dec!(10)),
556 a("v2", MaloKind::Vehicle, dec!(10)),
557 a("v3", MaloKind::Vehicle, dec!(10)),
558 ],
559 )
560 .unwrap();
561 assert!(r.proof.haelt());
562 let sum: Decimal = r.zuordnungen.iter().map(|z| z.kwh).sum();
563 assert_eq!(sum + r.delta_kwh, dec!(10));
564 }
565
566 #[test]
569 fn a_marktlokation_may_not_claim_the_same_slot_twice() {
570 let e = QuarterHourAllocation::allocate(
571 slot(),
572 Richtung::Bezug,
573 dec!(10),
574 &[
575 a("veh-1", MaloKind::Vehicle, dec!(4)),
576 a("veh-1", MaloKind::Vehicle, dec!(3)),
577 ],
578 )
579 .unwrap_err();
580 assert!(matches!(e, EmobError::DoppelterAnspruch { .. }), "{e:?}");
581 }
582
583 #[test]
584 fn negative_inputs_are_refused() {
585 assert!(QuarterHourAllocation::allocate(slot(), Richtung::Bezug, dec!(-1), &[]).is_err());
586 assert!(
587 QuarterHourAllocation::allocate(
588 slot(),
589 Richtung::Bezug,
590 dec!(1),
591 &[a("v1", MaloKind::Vehicle, dec!(-1))]
592 )
593 .is_err()
594 );
595 }
596
597 #[test]
599 fn the_two_directions_are_settled_apart() {
600 let bezug = QuarterHourAllocation::allocate(
601 slot(),
602 Richtung::Bezug,
603 dec!(10),
604 &[a("v1", MaloKind::Vehicle, dec!(10))],
605 )
606 .unwrap();
607 let einspeisung = QuarterHourAllocation::allocate(
608 slot(),
609 Richtung::Einspeisung,
610 dec!(4),
611 &[a("v1", MaloKind::Vehicle, dec!(4))],
612 )
613 .unwrap();
614 assert_eq!(bezug.zuordnungen[0].kwh, dec!(10));
615 assert_eq!(einspeisung.zuordnungen[0].kwh, dec!(4));
616 assert_ne!(bezug.richtung, einspeisung.richtung);
617 }
618
619 #[test]
620 fn a_version_totals_its_delta_and_flags_its_overclaims() {
621 let good = QuarterHourAllocation::allocate(
622 slot(),
623 Richtung::Bezug,
624 dec!(12),
625 &[a("v1", MaloKind::Vehicle, dec!(9))],
626 )
627 .unwrap();
628 let over = QuarterHourAllocation::allocate(
629 slot().next(),
630 Richtung::Bezug,
631 dec!(5),
632 &[a("v1", MaloKind::Vehicle, dec!(8))],
633 )
634 .unwrap();
635 let v = AllocationVersion {
636 erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
637 datenstatus: Datenstatus::Pruefdaten,
638 rows: vec![good, over],
639 };
640 assert_eq!(v.delta_kwh(), dec!(3));
641 assert_eq!(v.ueberdeckungen().count(), 1);
642 assert!(v.erhaltung_haelt());
643 assert!(!v.ist_final());
644 }
645
646 fn version(stamp: OffsetDateTime, status: Datenstatus) -> AllocationVersion {
647 AllocationVersion {
648 erstellungszeitpunkt: stamp,
649 datenstatus: status,
650 rows: vec![
651 QuarterHourAllocation::allocate(
652 slot(),
653 Richtung::Bezug,
654 dec!(10),
655 &[a("v1", MaloKind::Vehicle, dec!(6))],
656 )
657 .unwrap(),
658 ],
659 }
660 }
661
662 fn tag(y: i32, m: u8, d: u8) -> time::Date {
663 time::Date::from_calendar_date(y, time::Month::try_from(m).unwrap(), d).unwrap()
664 }
665
666 #[test]
667 fn a_series_takes_corrections_until_the_month_settles() {
668 let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
669 assert_eq!(reihe.korrekturfrist(), tag(2027, 6, 30));
670
671 reihe
672 .einreichen(
673 version(datetime!(2026-12-05 09:00:00 UTC), Datenstatus::Pruefdaten),
674 tag(2026, 12, 5),
675 )
676 .unwrap();
677 reihe
678 .einreichen(
679 version(
680 datetime!(2027-01-08 09:00:00 UTC),
681 Datenstatus::Abrechnungsdaten,
682 ),
683 tag(2027, 1, 8),
684 )
685 .unwrap();
686 assert_eq!(reihe.iter().count(), 2);
687 assert_eq!(
688 reihe.aktuell().unwrap().datenstatus,
689 Datenstatus::Abrechnungsdaten
690 );
691 }
692
693 #[test]
695 fn nothing_follows_an_abgerechnete_version() {
696 let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
697 reihe
698 .einreichen(
699 version(
700 datetime!(2027-01-08 09:00:00 UTC),
701 Datenstatus::AbgerechneteDaten,
702 ),
703 tag(2027, 1, 8),
704 )
705 .unwrap();
706 let e = reihe
707 .einreichen(
708 version(datetime!(2027-02-08 09:00:00 UTC), Datenstatus::Pruefdaten),
709 tag(2027, 2, 8),
710 )
711 .unwrap_err();
712 assert!(matches!(e, EmobError::VersionIstFinal { .. }), "{e:?}");
713 }
714
715 #[test]
716 fn a_filing_past_month_seven_is_refused() {
717 let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
718 let e = reihe
719 .einreichen(
720 version(datetime!(2027-07-01 09:00:00 UTC), Datenstatus::Pruefdaten),
721 tag(2027, 7, 1),
722 )
723 .unwrap_err();
724 match e {
725 EmobError::KorrekturfristAbgelaufen { monat, frist, .. } => {
726 assert_eq!(monat, "2026-11");
727 assert_eq!(frist, tag(2027, 6, 30));
728 }
729 other => panic!("{other:?}"),
730 }
731 }
732
733 #[test]
736 fn the_erstellungszeitpunkt_has_to_advance() {
737 let mut reihe = Versionsreihe::fuer(tag(2026, 11, 15));
738 let stamp = datetime!(2026-12-05 09:00:00 UTC);
739 reihe
740 .einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
741 .unwrap();
742 assert!(
743 reihe
744 .einreichen(version(stamp, Datenstatus::Pruefdaten), tag(2026, 12, 5))
745 .is_err()
746 );
747 }
748
749 #[test]
750 fn settled_versions_are_final() {
751 for status in [
752 Datenstatus::AbgerechneteDaten,
753 Datenstatus::AbgerechneteDatenKbka,
754 ] {
755 let v = AllocationVersion {
756 erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
757 datenstatus: status,
758 rows: Vec::new(),
759 };
760 assert!(v.ist_final(), "{status:?}");
761 }
762 for status in [
763 Datenstatus::Pruefdaten,
764 Datenstatus::Abrechnungsdaten,
765 Datenstatus::AbrechnungsdatenKbka,
766 ] {
767 let v = AllocationVersion {
768 erstellungszeitpunkt: datetime!(2026-11-04 09:00:00 UTC),
769 datenstatus: status,
770 rows: Vec::new(),
771 };
772 assert!(!v.ist_final(), "{status:?}");
773 }
774 }
775}