use crate::technology::ErzeugungsArt;
use rust_decimal::Decimal;
use rust_decimal::dec;
use time::Date;
#[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 SettlementPeriodState {
Active,
Reduced,
Suspended,
Interrupted,
PostEeg,
Ended,
}
impl SettlementPeriodState {
#[must_use]
pub fn is_payable(self) -> bool {
matches!(
self,
Self::Active | Self::Reduced | Self::PostEeg | Self::Interrupted
)
}
#[must_use]
pub fn requires_operator_action(self) -> bool {
matches!(self, Self::Suspended | Self::Reduced)
}
#[must_use]
pub fn is_terminal(self) -> bool {
self == Self::Ended
}
#[must_use]
pub fn to_db_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Reduced => "reduced",
Self::Suspended => "suspended",
Self::Interrupted => "interrupted",
Self::PostEeg => "post_eeg",
Self::Ended => "ended",
}
}
pub fn from_db_str(s: &str) -> Result<Self, InvalidSettlementPeriodState> {
match s {
"active" => Ok(Self::Active),
"reduced" => Ok(Self::Reduced),
"suspended" => Ok(Self::Suspended),
"interrupted" => Ok(Self::Interrupted),
"post_eeg" => Ok(Self::PostEeg),
"ended" => Ok(Self::Ended),
other => Err(InvalidSettlementPeriodState(other.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid settlement_period_state: '{0}'")]
pub struct InvalidSettlementPeriodState(pub String);
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct StateTransition {
pub from: SettlementPeriodState,
pub to: SettlementPeriodState,
pub effective_from: Date,
pub reason: StateTransitionReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum StateTransitionReason {
InitialCommissioning,
MastrRegistered,
FernsteuerbarkeitInstalled,
DirektvermarktungStarted,
DirektvermarktungEnded,
Sect52ViolationDetected,
Sect52ViolationResolved,
FoerderungExpired,
PostEegStarted,
Decommissioned,
Repowering,
}
#[derive(Debug, Clone, Copy)]
pub struct SettlementStateFacts {
pub mastr_registriert: bool,
pub sect9_anlage: Sect9Anlage,
pub sect9_erfuellung: Sect9Erfuellung,
pub foerderendedatum: Option<Date>,
pub billing_date: Date,
pub eeg_gesetz_year: i16,
}
#[must_use]
pub fn derive_settlement_state(facts: &SettlementStateFacts) -> SettlementPeriodState {
let &SettlementStateFacts {
mastr_registriert,
sect9_anlage,
sect9_erfuellung,
foerderendedatum,
billing_date,
eeg_gesetz_year,
} = facts;
if let Some(fed) = foerderendedatum
&& billing_date > fed
{
return SettlementPeriodState::PostEeg;
}
if !mastr_registriert {
return if eeg_gesetz_year >= 2023 {
SettlementPeriodState::Reduced
} else {
SettlementPeriodState::Suspended
};
}
if sect9_verletzt(sect9_anlage, sect9_erfuellung) {
return if eeg_gesetz_year >= 2023 {
SettlementPeriodState::Reduced
} else {
SettlementPeriodState::Reduced };
}
SettlementPeriodState::Active
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
#[test]
fn db_roundtrip_all_states() {
let states = [
SettlementPeriodState::Active,
SettlementPeriodState::Reduced,
SettlementPeriodState::Suspended,
SettlementPeriodState::Interrupted,
SettlementPeriodState::PostEeg,
SettlementPeriodState::Ended,
];
for s in states {
let db = s.to_db_str();
let parsed = SettlementPeriodState::from_db_str(db).unwrap();
assert_eq!(s, parsed, "roundtrip failed for {s:?}");
}
}
#[test]
fn unknown_db_str_returns_error() {
assert!(SettlementPeriodState::from_db_str("unknown").is_err());
}
#[test]
fn is_payable_states() {
assert!(SettlementPeriodState::Active.is_payable());
assert!(SettlementPeriodState::Reduced.is_payable());
assert!(SettlementPeriodState::PostEeg.is_payable());
assert!(SettlementPeriodState::Interrupted.is_payable());
assert!(!SettlementPeriodState::Suspended.is_payable());
assert!(!SettlementPeriodState::Ended.is_payable());
}
fn gesund() -> SettlementStateFacts {
SettlementStateFacts {
mastr_registriert: true,
sect9_anlage: Sect9Anlage {
leistung_kwp: dec!(50),
erzeugungsart: None,
einspeiseverguetung_oder_mieterstrom: true,
ist_kwk_anlage: false,
wechselrichterleistung_va: None,
},
sect9_erfuellung: Sect9Erfuellung::BEIDES,
foerderendedatum: Some(date!(2040 - 12 - 31)),
billing_date: date!(2026 - 07 - 01),
eeg_gesetz_year: 2023,
}
}
fn mit_leistung(kwp: rust_decimal::Decimal) -> Sect9Anlage {
Sect9Anlage {
leistung_kwp: kwp,
..gesund().sect9_anlage
}
}
#[test]
fn derive_active_healthy_plant() {
assert_eq!(
derive_settlement_state(&gesund()),
SettlementPeriodState::Active
);
}
#[test]
fn derive_post_eeg_expired() {
let facts = SettlementStateFacts {
foerderendedatum: Some(date!(2020 - 12 - 31)),
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::PostEeg
);
}
#[test]
fn derive_reduced_eeg2023_mastr_missing() {
let facts = SettlementStateFacts {
mastr_registriert: false,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Reduced
);
}
#[test]
fn derive_suspended_eeg2017_mastr_missing() {
let facts = SettlementStateFacts {
mastr_registriert: false,
eeg_gesetz_year: 2017,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Suspended
);
}
#[test]
fn derive_reduced_when_sect9_is_not_satisfied_at_all() {
let facts = SettlementStateFacts {
sect9_erfuellung: Sect9Erfuellung::KEINE,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Reduced
);
}
#[test]
fn the_sixty_percent_cap_alone_does_not_carry_a_50kw_plant() {
let facts = SettlementStateFacts {
sect9_erfuellung: Sect9Erfuellung::BEGRENZUNG_60,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Reduced
);
}
#[test]
fn a_directly_marketed_50kw_plant_needs_only_the_remote_control() {
let facts = SettlementStateFacts {
sect9_anlage: Sect9Anlage {
einspeiseverguetung_oder_mieterstrom: false,
..gesund().sect9_anlage
},
sect9_erfuellung: Sect9Erfuellung::FERNSTEUERBARKEIT,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Active
);
}
#[test]
fn the_sixty_percent_cap_does_not_carry_a_100kw_plant() {
let facts = SettlementStateFacts {
sect9_anlage: mit_leistung(dec!(100)),
sect9_erfuellung: Sect9Erfuellung::BEGRENZUNG_60,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Reduced
);
}
#[test]
fn derive_active_small_plant_on_the_cap() {
let facts = SettlementStateFacts {
sect9_anlage: mit_leistung(dec!(5)),
sect9_erfuellung: Sect9Erfuellung::BEGRENZUNG_60,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Active
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Sect9Erfuellung {
pub fernsteuerbarkeit: bool,
pub begrenzung_60: bool,
}
impl Sect9Erfuellung {
pub const KEINE: Self = Self {
fernsteuerbarkeit: false,
begrenzung_60: false,
};
pub const FERNSTEUERBARKEIT: Self = Self {
fernsteuerbarkeit: true,
begrenzung_60: false,
};
pub const BEGRENZUNG_60: Self = Self {
fernsteuerbarkeit: false,
begrenzung_60: true,
};
pub const BEIDES: Self = Self {
fernsteuerbarkeit: true,
begrenzung_60: true,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Sect9Anlage {
pub leistung_kwp: Decimal,
pub erzeugungsart: Option<ErzeugungsArt>,
pub einspeiseverguetung_oder_mieterstrom: bool,
pub ist_kwk_anlage: bool,
pub wechselrichterleistung_va: Option<Decimal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Sect9Pflicht {
pub fernsteuerbarkeit: bool,
pub begrenzung_60: bool,
}
impl Sect9Pflicht {
#[must_use]
pub const fn ist_leer(self) -> bool {
!self.fernsteuerbarkeit && !self.begrenzung_60
}
}
pub const STECKERSOLAR_LEISTUNG_KW: Decimal = dec!(2);
pub const STECKERSOLAR_WECHSELRICHTER_VA: Decimal = dec!(800);
#[must_use]
pub fn sect9_pflicht(anlage: Sect9Anlage) -> Sect9Pflicht {
let Sect9Anlage {
leistung_kwp,
erzeugungsart,
einspeiseverguetung_oder_mieterstrom,
ist_kwk_anlage,
wechselrichterleistung_va,
} = anlage;
let steckersolar_ausgenommen = erzeugungsart == Some(ErzeugungsArt::SolarStecker)
&& leistung_kwp <= STECKERSOLAR_LEISTUNG_KW
&& wechselrichterleistung_va.is_some_and(|va| va <= STECKERSOLAR_WECHSELRICHTER_VA);
let cap_adressat = einspeiseverguetung_oder_mieterstrom;
if leistung_kwp >= dec!(100) {
Sect9Pflicht {
fernsteuerbarkeit: true,
begrenzung_60: false,
}
} else if leistung_kwp >= dec!(25) {
Sect9Pflicht {
fernsteuerbarkeit: true,
begrenzung_60: cap_adressat,
}
} else {
Sect9Pflicht {
fernsteuerbarkeit: false,
begrenzung_60: (cap_adressat || ist_kwk_anlage) && !steckersolar_ausgenommen,
}
}
}
#[must_use]
pub fn sect9_verletzt(anlage: Sect9Anlage, erfuellung: Sect9Erfuellung) -> bool {
let pflicht = sect9_pflicht(anlage);
(pflicht.fernsteuerbarkeit && !erfuellung.fernsteuerbarkeit)
|| (pflicht.begrenzung_60 && !erfuellung.begrenzung_60)
}
#[cfg(test)]
mod sect9_tests {
use super::*;
use rust_decimal::dec;
fn anlage(kwp: Decimal) -> Sect9Anlage {
Sect9Anlage {
leistung_kwp: kwp,
erzeugungsart: None,
einspeiseverguetung_oder_mieterstrom: true,
ist_kwk_anlage: false,
wechselrichterleistung_va: None,
}
}
#[test]
fn the_middle_band_owes_both_routes() {
let a = anlage(dec!(50));
assert_eq!(
sect9_pflicht(a),
Sect9Pflicht {
fernsteuerbarkeit: true,
begrenzung_60: true
}
);
assert!(sect9_verletzt(a, Sect9Erfuellung::FERNSTEUERBARKEIT));
assert!(sect9_verletzt(a, Sect9Erfuellung::BEGRENZUNG_60));
assert!(sect9_verletzt(a, Sect9Erfuellung::KEINE));
assert!(!sect9_verletzt(a, Sect9Erfuellung::BEIDES));
}
#[test]
fn the_middle_band_in_direktvermarktung_owes_only_the_remote_control() {
let a = Sect9Anlage {
einspeiseverguetung_oder_mieterstrom: false,
..anlage(dec!(50))
};
assert_eq!(
sect9_pflicht(a),
Sect9Pflicht {
fernsteuerbarkeit: true,
begrenzung_60: false
}
);
assert!(!sect9_verletzt(a, Sect9Erfuellung::FERNSTEUERBARKEIT));
assert!(sect9_verletzt(a, Sect9Erfuellung::BEGRENZUNG_60));
}
#[test]
fn from_100_kw_only_the_remote_control_satisfies_sect9() {
for gefoerdert in [true, false] {
let a = Sect9Anlage {
einspeiseverguetung_oder_mieterstrom: gefoerdert,
..anlage(dec!(100))
};
assert_eq!(
sect9_pflicht(a),
Sect9Pflicht {
fernsteuerbarkeit: true,
begrenzung_60: false
}
);
assert!(sect9_verletzt(a, Sect9Erfuellung::BEGRENZUNG_60));
assert!(!sect9_verletzt(a, Sect9Erfuellung::FERNSTEUERBARKEIT));
}
}
#[test]
fn below_25_kw_the_cap_is_owed_only_by_the_addressees_of_nr_3() {
assert_eq!(
sect9_pflicht(anlage(dec!(10))),
Sect9Pflicht {
fernsteuerbarkeit: false,
begrenzung_60: true
}
);
assert!(sect9_verletzt(anlage(dec!(10)), Sect9Erfuellung::KEINE));
let direkt = Sect9Anlage {
einspeiseverguetung_oder_mieterstrom: false,
..anlage(dec!(10))
};
assert!(sect9_pflicht(direkt).ist_leer());
assert!(!sect9_verletzt(direkt, Sect9Erfuellung::KEINE));
let kwk = Sect9Anlage {
ist_kwk_anlage: true,
..direkt
};
assert!(sect9_verletzt(kwk, Sect9Erfuellung::KEINE));
assert!(!sect9_verletzt(kwk, Sect9Erfuellung::BEGRENZUNG_60));
}
#[test]
fn the_steckersolar_carve_out_is_a_two_part_test() {
let stecker = |kwp, va| Sect9Anlage {
erzeugungsart: Some(ErzeugungsArt::SolarStecker),
wechselrichterleistung_va: va,
..anlage(kwp)
};
for kwp in [dec!(0.8), dec!(2)] {
assert!(
sect9_pflicht(stecker(kwp, Some(dec!(800)))).ist_leer(),
"{kwp} kWp"
);
}
assert!(!sect9_pflicht(stecker(dec!(2.01), Some(dec!(800)))).ist_leer());
assert!(!sect9_pflicht(stecker(dec!(2), Some(dec!(1200)))).ist_leer());
assert!(!sect9_pflicht(stecker(dec!(2), None)).ist_leer());
}
#[test]
fn the_carve_out_does_not_reach_an_ordinary_small_plant() {
assert!(!sect9_pflicht(anlage(dec!(2))).ist_leer());
}
#[test]
fn exceeding_the_obligation_is_compliant() {
assert!(!sect9_verletzt(anlage(dec!(10)), Sect9Erfuellung::BEIDES));
assert!(!sect9_verletzt(anlage(dec!(120)), Sect9Erfuellung::BEIDES));
}
}