use rust_decimal::Decimal;
use rust_decimal::dec;
use time::Date;
use time::macros::date;
use crate::technology::ErzeugungsArt;
pub const DIREKTVERMARKTUNG_PFLICHT_KW: Decimal = dec!(100);
pub const SCHWELLE_VERIFIZIERT_AB: Date = date!(2016 - 01 - 01);
#[must_use]
pub fn direktvermarktungspflicht(leistung_kw: Decimal, inbetriebnahme: Date) -> Option<bool> {
(inbetriebnahme >= SCHWELLE_VERIFIZIERT_AB)
.then_some(leistung_kw > DIREKTVERMARKTUNG_PFLICHT_KW)
}
#[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 SolarSegment {
Erstes,
Zweites,
}
impl SolarSegment {
#[must_use]
pub fn ausschreibungsfreie_leistung_kw(self) -> Decimal {
match self {
Self::Erstes => dec!(1000),
Self::Zweites => dec!(750),
}
}
}
pub const WIND_AN_LAND_AUSSCHREIBUNGSFREI_KW: Decimal = dec!(1000);
pub const BIOMASSE_AUSSCHREIBUNGSFREI_KW: Decimal = dec!(150);
#[must_use]
pub fn requires_ausschreibung(
leistung_kw: Decimal,
art: ErzeugungsArt,
solar_segment: SolarSegment,
) -> bool {
match art {
ErzeugungsArt::SolarAufdach
| ErzeugungsArt::SolarFreiflaeche
| ErzeugungsArt::SolarAgriPv
| ErzeugungsArt::SolarMieterstrom
| ErzeugungsArt::SolarStecker => {
leistung_kw > solar_segment.ausschreibungsfreie_leistung_kw()
}
ErzeugungsArt::WindOnshore => leistung_kw > WIND_AN_LAND_AUSSCHREIBUNGSFREI_KW,
ErzeugungsArt::WindOffshore => true,
ErzeugungsArt::Biomasse
| ErzeugungsArt::BiomassHolz
| ErzeugungsArt::Biogas
| ErzeugungsArt::Biomethan => leistung_kw > BIOMASSE_AUSSCHREIBUNGSFREI_KW,
ErzeugungsArt::Wasserkraft
| ErzeugungsArt::Geothermie
| ErzeugungsArt::Klaergas
| ErzeugungsArt::Grubengas
| ErzeugungsArt::Deponiegas
| ErzeugungsArt::Gezeiten
| ErzeugungsArt::Kwk => false,
}
}
#[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 Veraeusserungsform {
Marktpraemie,
Einspeiseverguetung(EinspeiseverguetungsVariante),
Mieterstromzuschlag,
SonstigeDirektvermarktung,
}
#[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 EinspeiseverguetungsVariante {
GesetzlicherWert,
UnentgeltlicheAbnahme,
Ausfallverguetung,
AusgefoerderteAnlage,
}
impl Veraeusserungsform {
#[must_use]
pub fn ist_direktvermarktung(self) -> bool {
matches!(self, Self::Marktpraemie | Self::SonstigeDirektvermarktung)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum WechselAblehnung {
NichtZumMonatsersten,
MitteilungZuSpaet {
spaetestens_am: Date,
},
DirektvermarktungspflichtVerletzt,
AusfallverguetungNachUnentgeltlicherAbnahme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Wechsel {
pub ziel: Veraeusserungsform,
pub wirksam_ab: Date,
pub mitgeteilt_am: Date,
pub leistung_kw: Decimal,
pub inbetriebnahme: Date,
pub unentgeltliche_abnahme_in_24_monaten: bool,
}
pub fn validate_wechsel(w: &Wechsel) -> Result<(), WechselAblehnung> {
if w.wirksam_ab.day() != 1 {
return Err(WechselAblehnung::NichtZumMonatsersten);
}
let spaetestens_am = mitteilungsfrist(w.wirksam_ab);
if w.mitgeteilt_am > spaetestens_am {
return Err(WechselAblehnung::MitteilungZuSpaet { spaetestens_am });
}
if let Veraeusserungsform::Einspeiseverguetung(variante) = w.ziel {
if variante == EinspeiseverguetungsVariante::GesetzlicherWert
&& direktvermarktungspflicht(w.leistung_kw, w.inbetriebnahme) == Some(true)
{
return Err(WechselAblehnung::DirektvermarktungspflichtVerletzt);
}
if variante == EinspeiseverguetungsVariante::Ausfallverguetung
&& w.unentgeltliche_abnahme_in_24_monaten
{
return Err(WechselAblehnung::AusfallverguetungNachUnentgeltlicherAbnahme);
}
}
Ok(())
}
#[must_use]
pub fn mitteilungsfrist(wirksam_ab: Date) -> Date {
let vormonat = wirksam_ab.replace_day(1).unwrap_or(wirksam_ab);
vormonat
.previous_day()
.and_then(|d| d.replace_day(1).ok())
.and_then(time::Date::previous_day)
.unwrap_or(wirksam_ab)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_threshold_is_bis_zu_100_kw() {
let ibn = date!(2024 - 01 - 01);
assert_eq!(direktvermarktungspflicht(dec!(100.001), ibn), Some(true));
assert_eq!(direktvermarktungspflicht(dec!(100), ibn), Some(false));
assert_eq!(direktvermarktungspflicht(dec!(50), ibn), Some(false));
}
#[test]
fn a_pre_2016_plant_is_unanswered_not_exempt() {
assert_eq!(
direktvermarktungspflicht(dec!(600), date!(2015 - 12 - 31)),
None
);
assert_eq!(
direktvermarktungspflicht(dec!(600), date!(2016 - 01 - 01)),
Some(true)
);
}
#[test]
fn solar_thresholds_follow_the_segment_not_the_technology() {
assert!(!requires_ausschreibung(
dec!(1000),
ErzeugungsArt::SolarFreiflaeche,
SolarSegment::Erstes
));
assert!(requires_ausschreibung(
dec!(1000.001),
ErzeugungsArt::SolarFreiflaeche,
SolarSegment::Erstes
));
assert!(!requires_ausschreibung(
dec!(750),
ErzeugungsArt::SolarAufdach,
SolarSegment::Zweites
));
assert!(requires_ausschreibung(
dec!(900),
ErzeugungsArt::SolarAufdach,
SolarSegment::Zweites
));
}
#[test]
fn agri_pv_is_erstes_segment_and_has_no_6_mw_exemption() {
assert!(requires_ausschreibung(
dec!(5000),
ErzeugungsArt::SolarAgriPv,
SolarSegment::Erstes
));
}
#[test]
fn wind_an_land_is_exempt_up_to_one_megawatt() {
assert!(!requires_ausschreibung(
dec!(1000),
ErzeugungsArt::WindOnshore,
SolarSegment::Erstes
));
assert!(requires_ausschreibung(
dec!(1000.001),
ErzeugungsArt::WindOnshore,
SolarSegment::Erstes
));
}
#[test]
fn biomasse_is_exempt_up_to_150_kw() {
assert!(!requires_ausschreibung(
dec!(150),
ErzeugungsArt::Biomasse,
SolarSegment::Erstes
));
assert!(requires_ausschreibung(
dec!(151),
ErzeugungsArt::Biogas,
SolarSegment::Erstes
));
}
#[test]
fn statutory_technologies_are_never_tendered() {
for art in [
ErzeugungsArt::Wasserkraft,
ErzeugungsArt::Geothermie,
ErzeugungsArt::Deponiegas,
ErzeugungsArt::Klaergas,
ErzeugungsArt::Grubengas,
] {
assert!(
!requires_ausschreibung(dec!(50000), art, SolarSegment::Erstes),
"{art:?} has no EEG-Ausschreibung"
);
}
}
#[test]
fn only_two_forms_are_a_direktvermarktung() {
assert!(Veraeusserungsform::Marktpraemie.ist_direktvermarktung());
assert!(Veraeusserungsform::SonstigeDirektvermarktung.ist_direktvermarktung());
assert!(!Veraeusserungsform::Mieterstromzuschlag.ist_direktvermarktung());
assert!(
!Veraeusserungsform::Einspeiseverguetung(
EinspeiseverguetungsVariante::GesetzlicherWert
)
.ist_direktvermarktung()
);
}
fn wechsel(ziel: Veraeusserungsform) -> Wechsel {
Wechsel {
ziel,
wirksam_ab: date!(2026 - 07 - 01),
mitgeteilt_am: date!(2026 - 05 - 20),
leistung_kw: dec!(80),
inbetriebnahme: date!(2024 - 03 - 01),
unentgeltliche_abnahme_in_24_monaten: false,
}
}
#[test]
fn the_mitteilungsfrist_is_the_end_of_the_month_before_last() {
assert_eq!(
mitteilungsfrist(date!(2026 - 07 - 01)),
date!(2026 - 05 - 31)
);
assert_eq!(
mitteilungsfrist(date!(2026 - 01 - 01)),
date!(2025 - 11 - 30)
);
assert_eq!(
mitteilungsfrist(date!(2026 - 03 - 01)),
date!(2026 - 01 - 31)
);
}
#[test]
fn a_change_only_takes_effect_on_the_first() {
let w = Wechsel {
wirksam_ab: date!(2026 - 07 - 15),
..wechsel(Veraeusserungsform::Marktpraemie)
};
assert_eq!(
validate_wechsel(&w),
Err(WechselAblehnung::NichtZumMonatsersten)
);
}
#[test]
fn the_notice_must_precede_the_preceding_month() {
let base = wechsel(Veraeusserungsform::Marktpraemie);
assert!(
validate_wechsel(&Wechsel {
mitgeteilt_am: date!(2026 - 05 - 31),
..base
})
.is_ok()
);
assert_eq!(
validate_wechsel(&Wechsel {
mitgeteilt_am: date!(2026 - 06 - 01),
..base
}),
Err(WechselAblehnung::MitteilungZuSpaet {
spaetestens_am: date!(2026 - 05 - 31)
})
);
}
#[test]
fn a_large_plant_may_not_take_the_statutory_einspeiseverguetung() {
let ziel =
Veraeusserungsform::Einspeiseverguetung(EinspeiseverguetungsVariante::GesetzlicherWert);
let w = Wechsel {
leistung_kw: dec!(150),
..wechsel(ziel)
};
assert_eq!(
validate_wechsel(&w),
Err(WechselAblehnung::DirektvermarktungspflichtVerletzt)
);
}
#[test]
fn a_large_plant_may_still_take_the_other_three_varianten() {
for variante in [
EinspeiseverguetungsVariante::UnentgeltlicheAbnahme,
EinspeiseverguetungsVariante::Ausfallverguetung,
EinspeiseverguetungsVariante::AusgefoerderteAnlage,
] {
let w = Wechsel {
leistung_kw: dec!(150),
..wechsel(Veraeusserungsform::Einspeiseverguetung(variante))
};
assert!(validate_wechsel(&w).is_ok(), "{variante:?}");
}
}
#[test]
fn the_ausfallverguetung_is_closed_after_an_unentgeltliche_abnahme() {
let ziel = Veraeusserungsform::Einspeiseverguetung(
EinspeiseverguetungsVariante::Ausfallverguetung,
);
let w = Wechsel {
unentgeltliche_abnahme_in_24_monaten: true,
..wechsel(ziel)
};
assert_eq!(
validate_wechsel(&w),
Err(WechselAblehnung::AusfallverguetungNachUnentgeltlicherAbnahme)
);
}
#[test]
fn an_unanswerable_direktvermarktungspflicht_does_not_refuse() {
let ziel =
Veraeusserungsform::Einspeiseverguetung(EinspeiseverguetungsVariante::GesetzlicherWert);
let w = Wechsel {
leistung_kw: dec!(600),
inbetriebnahme: date!(2013 - 05 - 01),
..wechsel(ziel)
};
assert!(validate_wechsel(&w).is_ok());
}
}