use rust_decimal::Decimal;
use time::Date;
use time::macros::date;
pub const PFLICHT_CONSUMPTION_KWH_PER_YEAR: u32 = 6_000;
pub const PFLICHT_GENERATION_KW: u32 = 7;
pub const MME_DEADLINE: Date = date!(2032 - 12 - 31);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RolloutObligation {
PflichtConsumption,
PflichtSteuerbare14a,
PflichtGeneration,
Optionsfall,
}
impl RolloutObligation {
pub const ALL: [Self; 4] = [
Self::PflichtConsumption,
Self::PflichtSteuerbare14a,
Self::PflichtGeneration,
Self::Optionsfall,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PflichtConsumption => "PFLICHT_CONSUMPTION",
Self::PflichtSteuerbare14a => "PFLICHT_STEUERBARE14A",
Self::PflichtGeneration => "PFLICHT_GENERATION",
Self::Optionsfall => "OPTIONSFALL",
}
}
#[must_use]
pub const fn is_pflichteinbaufall(self) -> bool {
!matches!(self, Self::Optionsfall)
}
#[must_use]
pub const fn requires_steuerungseinrichtung(self) -> bool {
matches!(self, Self::PflichtSteuerbare14a | Self::PflichtGeneration)
}
#[must_use]
pub const fn is_quota_conditional(self) -> bool {
matches!(self, Self::PflichtGeneration)
}
#[must_use]
pub const fn legal_basis(self) -> &'static str {
match self {
Self::PflichtConsumption => "§ 29 Abs. 1 Nr. 1 MsbG",
Self::PflichtSteuerbare14a => "§ 29 Abs. 1 Nr. 2 Buchst. a MsbG",
Self::PflichtGeneration => "§ 29 Abs. 1 Nr. 2 Buchst. b MsbG",
Self::Optionsfall => "§ 29 Abs. 2 MsbG",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FeedInWaiver {
feed_in_limited_to_zero: bool,
declared_in_textform: bool,
}
impl FeedInWaiver {
pub const NONE: Self = Self {
feed_in_limited_to_zero: false,
declared_in_textform: false,
};
pub const GRANTED: Self = Self {
feed_in_limited_to_zero: true,
declared_in_textform: true,
};
#[must_use]
pub const fn new(feed_in_limited_to_zero: bool, declared_in_textform: bool) -> Self {
Self {
feed_in_limited_to_zero,
declared_in_textform,
}
}
#[must_use]
pub const fn feed_in_limited_to_zero(self) -> bool {
self.feed_in_limited_to_zero
}
#[must_use]
pub const fn declared_in_textform(self) -> bool {
self.declared_in_textform
}
#[must_use]
pub const fn applies(self) -> bool {
self.feed_in_limited_to_zero && self.declared_in_textform
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct RolloutAssessment {
pub grounds: Vec<RolloutObligation>,
pub steuerungseinrichtung_required: bool,
pub quota_conditional: bool,
}
impl RolloutAssessment {
#[must_use]
pub fn imsys_required(&self) -> bool {
self.grounds.iter().any(|g| g.is_pflichteinbaufall())
}
#[must_use]
pub fn has(&self, ground: RolloutObligation) -> bool {
self.grounds.contains(&ground)
}
#[must_use]
pub fn primary(&self) -> RolloutObligation {
self.grounds
.first()
.copied()
.unwrap_or(RolloutObligation::Optionsfall)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Jahresverbrauch {
Erfasst([Decimal; 3]),
Prognose(Decimal),
}
impl Jahresverbrauch {
fn above(self, threshold_kwh: u32) -> bool {
let threshold = Decimal::from(threshold_kwh);
match self {
Self::Erfasst(years) => years
.iter()
.try_fold(Decimal::ZERO, |s, y| s.checked_add(*y))
.is_none_or(|sum| sum > threshold * Decimal::from(3u32)),
Self::Prognose(kwh) => kwh > threshold,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Messstelle {
jahresverbrauch: Jahresverbrauch,
installierte_leistung_kw: Option<Decimal>,
vereinbarung_14a: bool,
einspeiseverzicht: FeedInWaiver,
}
impl Messstelle {
#[must_use]
pub const fn new(jahresverbrauch: Jahresverbrauch) -> Self {
Self {
jahresverbrauch,
installierte_leistung_kw: None,
vereinbarung_14a: false,
einspeiseverzicht: FeedInWaiver::NONE,
}
}
#[must_use]
pub const fn installierte_leistung(mut self, kw: Decimal) -> Self {
self.installierte_leistung_kw = Some(kw);
self
}
#[must_use]
pub const fn vereinbarung_14a(mut self, exists: bool) -> Self {
self.vereinbarung_14a = exists;
self
}
#[must_use]
pub const fn einspeiseverzicht(mut self, waiver: FeedInWaiver) -> Self {
self.einspeiseverzicht = waiver;
self
}
}
#[must_use]
pub fn classify_rollout_obligation(messstelle: &Messstelle) -> RolloutAssessment {
let mut grounds = Vec::new();
if messstelle
.jahresverbrauch
.above(PFLICHT_CONSUMPTION_KWH_PER_YEAR)
{
grounds.push(RolloutObligation::PflichtConsumption);
}
if messstelle.vereinbarung_14a {
grounds.push(RolloutObligation::PflichtSteuerbare14a);
}
if messstelle
.installierte_leistung_kw
.is_some_and(|kw| kw > Decimal::from(PFLICHT_GENERATION_KW))
{
grounds.push(RolloutObligation::PflichtGeneration);
}
if grounds.is_empty() {
grounds.push(RolloutObligation::Optionsfall);
}
let waiver = messstelle.einspeiseverzicht.applies();
let steuerungseinrichtung_required = grounds.iter().any(|g| {
g.requires_steuerungseinrichtung()
&& !(*g == RolloutObligation::PflichtGeneration && waiver)
});
RolloutAssessment {
quota_conditional: grounds == [RolloutObligation::PflichtGeneration],
grounds,
steuerungseinrichtung_required,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum QuotaScope {
TotalStock,
NewInWindow,
}
impl QuotaScope {
pub const ALL: [Self; 2] = [Self::TotalStock, Self::NewInWindow];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::TotalStock => "TOTAL_STOCK",
Self::NewInWindow => "NEW_IN_WINDOW",
}
}
}
crate::ids::codes::string_codes! {
RolloutObligation;
QuotaScope;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RolloutMilestone {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date_option"))]
window_from: Option<Date>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date"))]
deadline: Date,
quota_pct: u8,
scope: QuotaScope,
}
impl RolloutMilestone {
#[must_use]
pub const fn window_from(&self) -> Option<Date> {
self.window_from
}
#[must_use]
pub const fn deadline(&self) -> Date {
self.deadline
}
#[must_use]
pub const fn quota_pct(&self) -> u8 {
self.quota_pct
}
#[must_use]
pub const fn scope(&self) -> QuotaScope {
self.scope
}
}
pub const ROLLOUT_MILESTONES: [RolloutMilestone; 5] = [
RolloutMilestone {
window_from: None,
deadline: date!(2025 - 12 - 31),
quota_pct: 20,
scope: QuotaScope::TotalStock,
},
RolloutMilestone {
window_from: Some(date!(2025 - 02 - 25)),
deadline: date!(2026 - 12 - 31),
quota_pct: 90,
scope: QuotaScope::NewInWindow,
},
RolloutMilestone {
window_from: Some(date!(2027 - 01 - 01)),
deadline: date!(2028 - 12 - 31),
quota_pct: 90,
scope: QuotaScope::NewInWindow,
},
RolloutMilestone {
window_from: Some(date!(2029 - 01 - 01)),
deadline: date!(2030 - 12 - 31),
quota_pct: 90,
scope: QuotaScope::NewInWindow,
},
RolloutMilestone {
window_from: None,
deadline: date!(2032 - 12 - 31),
quota_pct: 90,
scope: QuotaScope::TotalStock,
},
];
#[must_use]
pub fn next_milestone(today: Date) -> Option<&'static RolloutMilestone> {
ROLLOUT_MILESTONES.iter().find(|m| m.deadline >= today)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
fn messstelle(consumption: Decimal, kw: Option<Decimal>, steuve: bool) -> Messstelle {
let m = Messstelle::new(Jahresverbrauch::Prognose(consumption)).vereinbarung_14a(steuve);
match kw {
Some(kw) => m.installierte_leistung(kw),
None => m,
}
}
fn plain(consumption: Decimal, kw: Option<Decimal>, steuve: bool) -> RolloutAssessment {
classify_rollout_obligation(&messstelle(consumption, kw, steuve))
}
#[test]
fn consumption_over_6000_kwh_is_pflicht_nr1() {
let a = plain(dec!(6001), None, false);
assert_eq!(a.grounds, vec![RolloutObligation::PflichtConsumption]);
assert!(a.imsys_required());
assert!(!a.steuerungseinrichtung_required);
assert!(!a.quota_conditional);
}
#[test]
fn exactly_6000_kwh_is_not_mandatory() {
let a = plain(dec!(6000), None, false);
assert_eq!(a.grounds, vec![RolloutObligation::Optionsfall]);
assert!(!a.imsys_required());
}
#[test]
fn the_grounds_are_cumulative() {
let a = plain(dec!(12000), Some(dec!(20)), true);
assert_eq!(
a.grounds,
vec![
RolloutObligation::PflichtConsumption,
RolloutObligation::PflichtSteuerbare14a,
RolloutObligation::PflichtGeneration,
]
);
assert_eq!(a.primary(), RolloutObligation::PflichtConsumption);
assert!(a.steuerungseinrichtung_required);
assert!(!a.quota_conditional);
}
#[test]
fn both_letters_of_nummer_2_owe_a_steuerungseinrichtung() {
assert!(RolloutObligation::PflichtSteuerbare14a.requires_steuerungseinrichtung());
assert!(RolloutObligation::PflichtGeneration.requires_steuerungseinrichtung());
assert!(!RolloutObligation::PflichtConsumption.requires_steuerungseinrichtung());
assert!(plain(dec!(0), Some(dec!(12)), false).steuerungseinrichtung_required);
}
#[test]
fn generation_over_7_kw_is_pflicht_nr2b_with_no_upper_cap() {
assert_eq!(
plain(dec!(0), Some(dec!(7.1)), false).primary(),
RolloutObligation::PflichtGeneration
);
assert_eq!(
plain(dec!(0), Some(dec!(950)), false).primary(),
RolloutObligation::PflichtGeneration
);
assert_eq!(
plain(dec!(0), Some(dec!(7)), false).primary(),
RolloutObligation::Optionsfall
);
}
#[test]
fn the_plant_ground_reports_its_condition() {
assert!(RolloutObligation::PflichtGeneration.is_quota_conditional());
assert!(!RolloutObligation::PflichtConsumption.is_quota_conditional());
assert!(plain(dec!(0), Some(dec!(12)), false).quota_conditional);
assert!(!plain(dec!(9000), Some(dec!(12)), false).quota_conditional);
}
#[test]
fn the_abs_5_waiver_lifts_only_the_control_unit() {
let waived = classify_rollout_obligation(
&messstelle(dec!(0), Some(dec!(12)), false).einspeiseverzicht(FeedInWaiver::GRANTED),
);
assert!(waived.imsys_required());
assert!(!waived.steuerungseinrichtung_required);
let steered = classify_rollout_obligation(
&messstelle(dec!(0), Some(dec!(12)), true).einspeiseverzicht(FeedInWaiver::GRANTED),
);
assert!(steered.steuerungseinrichtung_required);
}
#[test]
fn half_a_waiver_is_no_waiver() {
let half = FeedInWaiver::new(true, false);
assert!(!half.applies());
assert!(
classify_rollout_obligation(
&messstelle(dec!(0), Some(dec!(12)), false).einspeiseverzicht(half)
)
.steuerungseinrichtung_required
);
}
#[test]
fn milestones_are_ordered_and_end_2032() {
assert!(
ROLLOUT_MILESTONES
.windows(2)
.all(|w| w[0].deadline <= w[1].deadline)
);
assert_eq!(
next_milestone(date!(2026 - 07 - 01)).unwrap().deadline,
date!(2026 - 12 - 31)
);
assert!(next_milestone(date!(2033 - 01 - 01)).is_none());
}
#[test]
fn the_milestone_windows_are_the_statutes_own() {
let windows: Vec<_> = ROLLOUT_MILESTONES
.iter()
.map(|m| (m.window_from, m.deadline, m.quota_pct, m.scope))
.collect();
assert_eq!(
windows,
vec![
(None, date!(2025 - 12 - 31), 20, QuotaScope::TotalStock),
(
Some(date!(2025 - 02 - 25)),
date!(2026 - 12 - 31),
90,
QuotaScope::NewInWindow
),
(
Some(date!(2027 - 01 - 01)),
date!(2028 - 12 - 31),
90,
QuotaScope::NewInWindow
),
(
Some(date!(2029 - 01 - 01)),
date!(2030 - 12 - 31),
90,
QuotaScope::NewInWindow
),
(None, date!(2032 - 12 - 31), 90, QuotaScope::TotalStock),
]
);
}
#[test]
fn the_three_year_mean_decides() {
let erfasst = |a, b, c| {
classify_rollout_obligation(&Messstelle::new(Jahresverbrauch::Erfasst([a, b, c])))
.has(RolloutObligation::PflichtConsumption)
};
assert!(
!erfasst(dec!(6000), dec!(6000), dec!(6000)),
"exactly 6 000 is not more"
);
assert!(erfasst(dec!(6000), dec!(6000), dec!(6000.01)));
assert!(
!erfasst(dec!(12000), dec!(3000), dec!(2999)),
"one heavy year"
);
assert!(erfasst(dec!(12000), dec!(3000), dec!(3001)));
}
#[test]
fn an_optionsfall_still_owes_a_moderne_messeinrichtung() {
let a = plain(dec!(500), None, false);
assert!(!a.imsys_required());
assert_eq!(MME_DEADLINE, date!(2032 - 12 - 31));
}
}