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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
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 fn is_pflichteinbaufall(self) -> bool {
!matches!(self, Self::Optionsfall)
}
#[must_use]
pub fn requires_steuerungseinrichtung(self) -> bool {
matches!(self, Self::PflichtSteuerbare14a)
}
#[must_use]
pub fn legal_basis(self) -> &'static str {
match self {
Self::PflichtConsumption => "§29 Abs. 1 Nr. 1 MsbG",
Self::PflichtSteuerbare14a => "§29 Abs. 1 Nr. 2a MsbG",
Self::PflichtGeneration => "§29 Abs. 1 Nr. 2b MsbG",
Self::Optionsfall => "§29 Abs. 2 MsbG",
}
}
}
#[must_use]
pub fn classify_rollout_obligation(
annual_consumption_kwh: Decimal,
installed_generation_kw: Option<Decimal>,
has_14a_agreement: bool,
) -> RolloutObligation {
if has_14a_agreement {
return RolloutObligation::PflichtSteuerbare14a;
}
if let Some(kw) = installed_generation_kw
&& kw > Decimal::from(PFLICHT_GENERATION_KW)
{
return RolloutObligation::PflichtGeneration;
}
if annual_consumption_kwh > Decimal::from(PFLICHT_CONSUMPTION_KWH_PER_YEAR) {
return RolloutObligation::PflichtConsumption;
}
RolloutObligation::Optionsfall
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
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::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"))]
pub window_from: Option<Date>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date"))]
pub deadline: Date,
pub quota_pct: u8,
pub scope: QuotaScope,
}
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;
#[test]
fn consumption_over_6000_kwh_is_pflicht_nr1() {
let o = classify_rollout_obligation(dec!(6001), None, false);
assert_eq!(o, RolloutObligation::PflichtConsumption);
assert!(o.is_pflichteinbaufall());
assert!(!o.requires_steuerungseinrichtung());
}
#[test]
fn exactly_6000_kwh_is_not_mandatory() {
assert_eq!(
classify_rollout_obligation(dec!(6000), None, false),
RolloutObligation::Optionsfall
);
}
#[test]
fn a_14a_agreement_dominates_and_needs_steuerung() {
let o = classify_rollout_obligation(dec!(12000), Some(dec!(20)), true);
assert_eq!(o, RolloutObligation::PflichtSteuerbare14a);
assert!(o.requires_steuerungseinrichtung());
assert_eq!(o.legal_basis(), "§29 Abs. 1 Nr. 2a MsbG");
}
#[test]
fn generation_over_7_kw_is_pflicht_nr2b_with_no_upper_cap() {
assert_eq!(
classify_rollout_obligation(dec!(0), Some(dec!(7.1)), false),
RolloutObligation::PflichtGeneration
);
assert_eq!(
classify_rollout_obligation(dec!(0), Some(dec!(950)), false),
RolloutObligation::PflichtGeneration
);
assert_eq!(
classify_rollout_obligation(dec!(0), Some(dec!(7)), false),
RolloutObligation::Optionsfall
);
}
#[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());
}
}