use crate::technology::ErzeugungsArt;
use rust_decimal::{Decimal, dec};
use time::{Date, OffsetDateTime, macros::date};
pub const SOLARSPITZENGESETZ_INKRAFTTRETEN: Date = date!(2025 - 02 - 25);
pub const SECT51_KLEINSTANLAGEN_GRENZE_KW: Decimal = dec!(2);
pub const SECT51_OPTIN_ZUSCHLAG_CT_KWH: Decimal = dec!(0.6);
#[must_use]
pub fn optin_wirksam_ab(erklaert_am: Date, imesys_rollout: Option<Date>) -> Option<Date> {
let imesys = imesys_rollout?;
let nach_imesys_jahr =
Date::from_calendar_date(imesys.year() + 1, time::Month::January, 1).ok()?;
let nach_erklaerung =
Date::from_calendar_date(erklaert_am.year() + 1, time::Month::January, 1).ok()?;
Some(nach_imesys_jahr.max(nach_erklaerung))
}
#[must_use]
pub fn imesys_befreiung_entfaellt_ab(imesys_rollout: Option<Date>) -> Option<Date> {
let imesys = imesys_rollout?;
Date::from_calendar_date(imesys.year() + 1, time::Month::January, 1).ok()
}
#[must_use]
pub fn imesys_befreiung_entfallen(
imesys_rollout: Option<Date>,
periodenbeginn: Option<Date>,
) -> bool {
match (
imesys_befreiung_entfaellt_ab(imesys_rollout),
periodenbeginn,
) {
(Some(ab), Some(periode)) => periode >= ab,
_ => 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 NegativpreisRegime {
Keine,
Eeg2017,
Eeg2021,
Eeg2023Gestaffelt {
stunden: u8,
},
Solarspitzen,
}
impl NegativpreisRegime {
#[must_use]
pub fn fuer_periode(
inbetriebnahme: Date,
optin_wirksam_ab: Option<Date>,
billing_date: Option<Date>,
) -> Self {
if let (Some(ab), Some(bd)) = (optin_wirksam_ab, billing_date)
&& bd >= ab
{
return Self::Solarspitzen;
}
Self::fuer_inbetriebnahme(inbetriebnahme)
}
#[must_use]
pub fn fuer_inbetriebnahme(inbetriebnahme: Date) -> Self {
if inbetriebnahme >= SOLARSPITZENGESETZ_INKRAFTTRETEN {
return Self::Solarspitzen;
}
match inbetriebnahme.year() {
..=2015 => Self::Keine,
2016..=2020 => Self::Eeg2017,
2021..=2022 => Self::Eeg2021,
2023 => Self::Eeg2023Gestaffelt { stunden: 4 },
2024..=2025 => Self::Eeg2023Gestaffelt { stunden: 3 },
2026..=2027 => Self::Eeg2023Gestaffelt { stunden: 2 },
_ => Self::Eeg2023Gestaffelt { stunden: 1 },
}
}
#[must_use]
pub fn mindest_lauflaenge_qh(self) -> Option<usize> {
match self {
Self::Keine => None,
Self::Eeg2017 => Some(24),
Self::Eeg2021 => Some(16),
Self::Eeg2023Gestaffelt { stunden } => Some(usize::from(stunden) * 4),
Self::Solarspitzen => Some(1),
}
}
#[must_use]
pub fn kw_grenze(self, art: Option<ErzeugungsArt>) -> Option<Decimal> {
match self {
Self::Keine => None,
Self::Eeg2017 => Some(if art.is_some_and(|a| a.is_wind()) {
dec!(3000)
} else {
dec!(500)
}),
Self::Eeg2021 => Some(dec!(500)),
Self::Eeg2023Gestaffelt { .. } => Some(dec!(400)),
Self::Solarspitzen => Some(dec!(100)),
}
}
#[must_use]
pub fn ist_befreit(
self,
leistung_kwp: Option<Decimal>,
art: Option<ErzeugungsArt>,
has_imesys: bool,
ist_pilotwindanlage: bool,
) -> bool {
let Some(grenze) = self.kw_grenze(art) else {
return true; };
if ist_pilotwindanlage {
return true;
}
let Some(kw) = leistung_kwp else {
return false;
};
if self == Self::Solarspitzen {
if kw < SECT51_KLEINSTANLAGEN_GRENZE_KW {
return true;
}
return kw < grenze && !has_imesys;
}
kw < grenze
}
#[must_use]
pub fn verlaengerungsanspruch(self, ist_ausschreibungspflichtig: bool) -> bool {
match self {
Self::Keine | Self::Eeg2017 => false,
Self::Eeg2021 | Self::Eeg2023Gestaffelt { .. } => ist_ausschreibungspflichtig,
Self::Solarspitzen => true,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Keine => "§51 nicht anwendbar (Inbetriebnahme vor 2016)",
Self::Eeg2017 => "§51 EEG 2017 (6-Stunden-Regel)",
Self::Eeg2021 => "§51 EEG 2021 (4-Stunden-Regel)",
Self::Eeg2023Gestaffelt { stunden: 4 } => "§51 EEG 2023 (4-Stunden-Regel)",
Self::Eeg2023Gestaffelt { stunden: 3 } => "§51 EEG 2023 (3-Stunden-Regel)",
Self::Eeg2023Gestaffelt { stunden: 2 } => "§51 EEG 2023 (2-Stunden-Regel)",
Self::Eeg2023Gestaffelt { .. } => "§51 EEG 2023 (1-Stunden-Regel)",
Self::Solarspitzen => "§51 EEG i.d.F. Solarspitzengesetz (ab erster Viertelstunde)",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct NegativpreisInterval {
pub start: OffsetDateTime,
pub feed_in_kwh: Decimal,
pub price_negative: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NegativpreisResult {
pub kwh_during_negative: Decimal,
pub negative_quarter_hours: u64,
}
#[must_use]
pub fn derive_negativpreis(
intervals: &[NegativpreisInterval],
regime: NegativpreisRegime,
) -> NegativpreisResult {
let Some(min_run_qh) = regime.mindest_lauflaenge_qh() else {
return NegativpreisResult::default();
};
let mut result = NegativpreisResult::default();
let mut i = 0;
while i < intervals.len() {
if !intervals[i].price_negative {
i += 1;
continue;
}
let run_start = i;
let mut j = i + 1;
while j < intervals.len()
&& intervals[j].price_negative
&& intervals[j].start == intervals[j - 1].start + time::Duration::minutes(15)
{
j += 1;
}
if j - run_start >= min_run_qh {
for iv in &intervals[run_start..j] {
result.kwh_during_negative += iv.feed_in_kwh.max(Decimal::ZERO);
result.negative_quarter_hours += 1;
}
}
i = j;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
fn qh(n: i64, kwh: &str, neg: bool) -> NegativpreisInterval {
NegativpreisInterval {
start: datetime!(2026-06-01 00:00 UTC) + time::Duration::minutes(15 * n),
feed_in_kwh: kwh.parse::<Decimal>().unwrap(),
price_negative: neg,
}
}
#[test]
fn the_solarspitzengesetz_boundary_is_a_date_not_a_year() {
assert_eq!(
NegativpreisRegime::fuer_inbetriebnahme(date!(2025 - 02 - 24)),
NegativpreisRegime::Eeg2023Gestaffelt { stunden: 3 }
);
assert_eq!(
NegativpreisRegime::fuer_inbetriebnahme(date!(2025 - 02 - 25)),
NegativpreisRegime::Solarspitzen
);
}
#[test]
fn regime_boundaries_follow_the_clearingstelle_table() {
for (ibn, want) in [
(date!(2015 - 12 - 31), NegativpreisRegime::Keine),
(date!(2016 - 01 - 01), NegativpreisRegime::Eeg2017),
(date!(2020 - 12 - 31), NegativpreisRegime::Eeg2017),
(date!(2021 - 01 - 01), NegativpreisRegime::Eeg2021),
(date!(2022 - 12 - 31), NegativpreisRegime::Eeg2021),
(
date!(2023 - 01 - 01),
NegativpreisRegime::Eeg2023Gestaffelt { stunden: 4 },
),
(
date!(2024 - 01 - 01),
NegativpreisRegime::Eeg2023Gestaffelt { stunden: 3 },
),
(date!(2026 - 06 - 01), NegativpreisRegime::Solarspitzen),
] {
assert_eq!(
NegativpreisRegime::fuer_inbetriebnahme(ibn),
want,
"Inbetriebnahme {ibn}"
);
}
}
#[test]
fn solarspitzen_counts_a_single_quarter_hour() {
let ivs = [qh(0, "10", false), qh(1, "12", true), qh(2, "8", false)];
let r = derive_negativpreis(&ivs, NegativpreisRegime::Solarspitzen);
assert_eq!(r.kwh_during_negative, dec!(12));
assert_eq!(r.negative_quarter_hours, 1);
}
#[test]
fn a_2024_plant_needs_three_consecutive_hours() {
let regime = NegativpreisRegime::fuer_inbetriebnahme(date!(2024 - 07 - 01));
let short: Vec<_> = (0..11).map(|n| qh(n, "5", true)).collect();
assert_eq!(
derive_negativpreis(&short, regime),
NegativpreisResult::default()
);
let long: Vec<_> = (0..12).map(|n| qh(n, "5", true)).collect();
let r = derive_negativpreis(&long, regime);
assert_eq!(r.kwh_during_negative, dec!(60));
assert_eq!(r.negative_quarter_hours, 12);
assert_eq!(
derive_negativpreis(&short, NegativpreisRegime::Solarspitzen).negative_quarter_hours,
11
);
}
#[test]
fn eeg2021_requires_four_consecutive_hours() {
let short: Vec<_> = (0..8).map(|n| qh(n, "5", true)).collect();
assert_eq!(
derive_negativpreis(&short, NegativpreisRegime::Eeg2021),
NegativpreisResult::default()
);
let long: Vec<_> = (0..16).map(|n| qh(n, "5", true)).collect();
assert_eq!(
derive_negativpreis(&long, NegativpreisRegime::Eeg2021).kwh_during_negative,
dec!(80)
);
}
#[test]
fn a_gap_breaks_the_consecutive_run() {
let mut ivs: Vec<_> = (0..15).map(|n| qh(n, "5", true)).collect();
ivs.push(qh(15, "5", false));
ivs.push(qh(16, "5", true));
assert_eq!(
derive_negativpreis(&ivs, NegativpreisRegime::Eeg2021),
NegativpreisResult::default()
);
}
#[test]
fn negative_feed_in_is_floored() {
let ivs = [qh(0, "-3", true)];
assert_eq!(
derive_negativpreis(&ivs, NegativpreisRegime::Solarspitzen).kwh_during_negative,
dec!(0)
);
}
#[test]
fn the_original_eeg2023_exemption_is_400_kw() {
let regime = NegativpreisRegime::fuer_inbetriebnahme(date!(2024 - 03 - 01));
assert!(regime.ist_befreit(Some(dec!(399)), None, true, false));
assert!(!regime.ist_befreit(Some(dec!(400)), None, false, false));
}
#[test]
fn solarspitzen_exemptions_are_layered() {
let r = NegativpreisRegime::Solarspitzen;
assert!(r.ist_befreit(Some(dec!(1.5)), None, true, false));
assert!(r.ist_befreit(Some(dec!(30)), None, false, false));
assert!(!r.ist_befreit(Some(dec!(30)), None, true, false));
assert!(!r.ist_befreit(Some(dec!(100)), None, false, false));
}
#[test]
fn eeg2017_keeps_the_wind_carve_out() {
let r = NegativpreisRegime::Eeg2017;
assert!(r.ist_befreit(
Some(dec!(2999)),
Some(ErzeugungsArt::WindOnshore),
false,
false
));
assert!(!r.ist_befreit(
Some(dec!(600)),
Some(ErzeugungsArt::SolarAufdach),
false,
false
));
}
#[test]
fn a_pilotwindanlage_is_exempt_under_every_version() {
for r in [
NegativpreisRegime::Eeg2017,
NegativpreisRegime::Eeg2021,
NegativpreisRegime::Eeg2023Gestaffelt { stunden: 3 },
NegativpreisRegime::Solarspitzen,
] {
assert!(
r.ist_befreit(
Some(dec!(5000)),
Some(ErzeugungsArt::WindOnshore),
true,
true
),
"{r:?}"
);
}
}
#[test]
fn unknown_capacity_is_not_exempt() {
assert!(!NegativpreisRegime::Solarspitzen.ist_befreit(None, None, false, false));
}
#[test]
fn the_optin_takes_effect_at_the_year_end_after_the_imsys() {
assert_eq!(
optin_wirksam_ab(date!(2026 - 03 - 01), Some(date!(2026 - 09 - 01))),
Some(date!(2027 - 01 - 01))
);
assert_eq!(
optin_wirksam_ab(date!(2028 - 05 - 01), Some(date!(2026 - 09 - 01))),
Some(date!(2029 - 01 - 01))
);
assert_eq!(optin_wirksam_ab(date!(2026 - 03 - 01), None), None);
}
#[test]
fn an_effective_optin_moves_a_bestandsanlage_to_the_new_regime() {
let ibn = date!(2019 - 06 - 01);
let ab = Some(date!(2027 - 01 - 01));
assert_eq!(
NegativpreisRegime::fuer_periode(ibn, ab, Some(date!(2026 - 12 - 01))),
NegativpreisRegime::Eeg2017,
"before the effective date the old regime still governs"
);
assert_eq!(
NegativpreisRegime::fuer_periode(ibn, ab, Some(date!(2027 - 01 - 01))),
NegativpreisRegime::Solarspitzen
);
assert_eq!(
NegativpreisRegime::fuer_periode(ibn, None, Some(date!(2030 - 01 - 01))),
NegativpreisRegime::Eeg2017,
"without a declaration the vintage governs for the whole Förderdauer"
);
}
#[test]
fn sect51a_extension_predates_solarspitzen_only_for_auction_plants() {
let staffel = NegativpreisRegime::Eeg2023Gestaffelt { stunden: 3 };
assert!(staffel.verlaengerungsanspruch(true));
assert!(!staffel.verlaengerungsanspruch(false));
assert!(NegativpreisRegime::Solarspitzen.verlaengerungsanspruch(false));
assert!(!NegativpreisRegime::Eeg2017.verlaengerungsanspruch(true));
}
}