use crate::technology::ErzeugungsArt;
use rust_decimal::Decimal;
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,
FernsteuerbarkeitmInstalled,
DirektvermarktungStarted,
DirektvermarktungEnded,
Sect52ViolationDetected,
Sect52ViolationResolved,
FoerderungExpired,
PostEegStarted,
Decommissioned,
Repowering,
}
#[derive(Debug, Clone, Copy)]
pub struct SettlementStateFacts {
pub mastr_registriert: bool,
pub sect9_erfuellung: crate::settlement_state::Sect9Erfuellung,
pub leistung_kwp: rust_decimal::Decimal,
pub erzeugungsart: Option<ErzeugungsArt>,
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_erfuellung,
leistung_kwp,
erzeugungsart: art,
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(leistung_kwp, art, 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_erfuellung: Sect9Erfuellung::Fernsteuerbarkeit,
leistung_kwp: dec!(50),
erzeugungsart: None,
foerderendedatum: Some(date!(2040 - 12 - 31)),
billing_date: date!(2026 - 07 - 01),
eeg_gesetz_year: 2023,
}
}
#[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_keeps_a_50kw_plant_active() {
let facts = SettlementStateFacts {
sect9_erfuellung: Sect9Erfuellung::Leistungsbegrenzung60,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Active
);
}
#[test]
fn the_sixty_percent_cap_does_not_carry_a_100kw_plant() {
let facts = SettlementStateFacts {
leistung_kwp: dec!(100),
sect9_erfuellung: Sect9Erfuellung::Leistungsbegrenzung60,
..gesund()
};
assert_eq!(
derive_settlement_state(&facts),
SettlementPeriodState::Reduced
);
}
#[test]
fn derive_active_small_plant_on_the_cap() {
let facts = SettlementStateFacts {
leistung_kwp: dec!(5),
sect9_erfuellung: Sect9Erfuellung::Leistungsbegrenzung60,
..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))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Sect9Erfuellung {
#[default]
Keine,
Fernsteuerbarkeit,
Leistungsbegrenzung60,
}
#[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 Sect9Pflicht {
Keine,
Leistungsbegrenzung60,
FernsteuerbarkeitOderBegrenzung,
Fernsteuerbarkeit,
}
#[must_use]
pub fn sect9_pflicht(leistung_kwp: Decimal, art: Option<ErzeugungsArt>) -> Sect9Pflicht {
use rust_decimal::dec;
if art == Some(ErzeugungsArt::SolarStecker) && leistung_kwp < dec!(2) {
return Sect9Pflicht::Keine;
}
if leistung_kwp >= dec!(100) {
Sect9Pflicht::Fernsteuerbarkeit
} else if leistung_kwp >= dec!(25) {
Sect9Pflicht::FernsteuerbarkeitOderBegrenzung
} else {
Sect9Pflicht::Leistungsbegrenzung60
}
}
#[must_use]
pub fn sect9_verletzt(
leistung_kwp: Decimal,
art: Option<ErzeugungsArt>,
erfuellung: Sect9Erfuellung,
) -> bool {
match sect9_pflicht(leistung_kwp, art) {
Sect9Pflicht::Keine => false,
Sect9Pflicht::Fernsteuerbarkeit => erfuellung != Sect9Erfuellung::Fernsteuerbarkeit,
Sect9Pflicht::FernsteuerbarkeitOderBegrenzung | Sect9Pflicht::Leistungsbegrenzung60 => {
erfuellung == Sect9Erfuellung::Keine
}
}
}
#[cfg(test)]
mod sect9_tests {
use super::*;
use rust_decimal::dec;
#[test]
fn the_middle_band_may_take_either_route() {
for e in [
Sect9Erfuellung::Fernsteuerbarkeit,
Sect9Erfuellung::Leistungsbegrenzung60,
] {
assert!(!sect9_verletzt(dec!(50), None, e), "{e:?}");
}
assert!(sect9_verletzt(dec!(50), None, Sect9Erfuellung::Keine));
}
#[test]
fn from_100_kw_only_fernsteuerbarkeit_satisfies_sect9() {
assert!(sect9_verletzt(
dec!(100),
None,
Sect9Erfuellung::Leistungsbegrenzung60
));
assert!(!sect9_verletzt(
dec!(100),
None,
Sect9Erfuellung::Fernsteuerbarkeit
));
}
#[test]
fn below_25_kw_the_sixty_percent_cap_is_enough() {
assert!(!sect9_verletzt(
dec!(10),
None,
Sect9Erfuellung::Leistungsbegrenzung60
));
assert!(sect9_verletzt(dec!(10), None, Sect9Erfuellung::Keine));
}
#[test]
fn a_steckersolargeraet_is_exempt() {
assert!(!sect9_verletzt(
dec!(0.8),
Some(ErzeugungsArt::SolarStecker),
Sect9Erfuellung::Keine
));
assert!(sect9_verletzt(
dec!(2),
Some(ErzeugungsArt::SolarStecker),
Sect9Erfuellung::Keine
));
}
}