use billing::{Amount, BillingError, RateLookup};
use rust_decimal::Decimal;
use rust_decimal::dec;
pub const SOLAR_GEBAEUDE_BASIS_CT: [(Decimal, Decimal); 3] = [
(dec!(10), dec!(8.60)),
(dec!(40), dec!(7.50)),
(dec!(1_000), dec!(6.20)),
];
pub const SOLAR_VOLLEINSPEISUNG_ZUSCHLAG_CT: [(Decimal, Decimal); 5] = [
(dec!(10), dec!(4.8)),
(dec!(40), dec!(3.8)),
(dec!(100), dec!(5.1)),
(dec!(400), dec!(3.2)),
(dec!(1_000), dec!(1.9)),
];
pub const SOLAR_FREIFLAECHE_BASIS_CT: Decimal = dec!(7.00);
const EEG2023_START: time::Date = time::macros::date!(2023 - 01 - 01);
fn tier_ct(table: &[(Decimal, Decimal)], leistung_kwp: Decimal) -> Option<Decimal> {
table
.iter()
.find(|(max, _)| leistung_kwp <= *max)
.map(|(_, ct)| *ct)
}
#[must_use]
pub fn solar_pv_ueberschuss_aw_ct(
leistung_kwp: Decimal,
inbetriebnahme: time::Date,
) -> Option<Decimal> {
if inbetriebnahme < EEG2023_START {
return None;
}
let base = tier_ct(&SOLAR_GEBAEUDE_BASIS_CT, leistung_kwp)?;
Some(crate::degression::anzulegender_wert_bei_inbetriebnahme(
base,
inbetriebnahme,
))
}
#[must_use]
pub fn solar_pv_volleinspeisung_aw_ct(
leistung_kwp: Decimal,
inbetriebnahme: time::Date,
) -> Option<Decimal> {
if inbetriebnahme < EEG2023_START {
return None;
}
let base = tier_ct(&SOLAR_GEBAEUDE_BASIS_CT, leistung_kwp)?;
let zuschlag = tier_ct(&SOLAR_VOLLEINSPEISUNG_ZUSCHLAG_CT, leistung_kwp)?;
Some(crate::degression::anzulegender_wert_bei_inbetriebnahme(
base + zuschlag,
inbetriebnahme,
))
}
#[must_use]
pub fn solar_pv_freiflaeche_aw_ct(inbetriebnahme: time::Date) -> Option<Decimal> {
(inbetriebnahme >= EEG2023_START).then(|| {
crate::degression::anzulegender_wert_bei_inbetriebnahme(
SOLAR_FREIFLAECHE_BASIS_CT,
inbetriebnahme,
)
})
}
#[must_use]
pub fn solar_pv_ueberschuss_lookup(inbetriebnahme: time::Date) -> Option<RateLookup> {
if inbetriebnahme < EEG2023_START {
return None;
}
let at = |kwp| solar_pv_ueberschuss_aw_ct(kwp, inbetriebnahme).unwrap_or(Decimal::ZERO);
RateLookup::builder()
.at_most(dec!(10), amount_from_ct(at(dec!(10))))
.at_most(dec!(40), amount_from_ct(at(dec!(40))))
.at_most(dec!(1_000), amount_from_ct(at(dec!(1_000))))
.build()
.ok()
}
#[must_use]
pub fn solar_pv_volleinspeisung_lookup(inbetriebnahme: time::Date) -> Option<RateLookup> {
if inbetriebnahme < EEG2023_START {
return None;
}
let at = |kwp| solar_pv_volleinspeisung_aw_ct(kwp, inbetriebnahme).unwrap_or(Decimal::ZERO);
RateLookup::builder()
.at_most(dec!(10), amount_from_ct(at(dec!(10))))
.at_most(dec!(40), amount_from_ct(at(dec!(40))))
.at_most(dec!(100), amount_from_ct(at(dec!(100))))
.at_most(dec!(400), amount_from_ct(at(dec!(400))))
.at_most(dec!(1_000), amount_from_ct(at(dec!(1_000))))
.build()
.ok()
}
#[must_use]
pub fn wind_onshore_lookup(_eeg_year: i16) -> Option<RateLookup> {
None
}
pub fn biomasse_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(150), amount_ct("12.67"))
.build()
.ok(),
_ => None,
}
}
pub fn bioabfall_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(500), amount_ct("14.16"))
.at_most(dec!(20_000), amount_ct("12.41"))
.build()
.ok(),
_ => None,
}
}
pub fn guelle_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(75), amount_ct("22.00"))
.at_most(dec!(150), amount_ct("19.00"))
.build()
.ok(),
_ => None,
}
}
fn amount_from_ct(ct: rust_decimal::Decimal) -> Amount<5> {
Amount::from_decimal_rounded(
ct / rust_decimal::Decimal::from(100u32),
billing::RoundingStrategy::MidpointAwayFromZero,
)
.expect("statutory rate is in range")
}
fn amount_ct(ct_str: &str) -> Amount<5> {
amount_from_ct(ct_str.parse().expect("static rate string"))
}
pub fn wasserkraft_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(500), amount_ct("12.03"))
.at_most(dec!(2_000), amount_ct("7.93"))
.at_most(dec!(5_000), amount_ct("6.07"))
.at_most(dec!(10_000), amount_ct("5.32"))
.at_most(dec!(20_000), amount_ct("5.13"))
.at_most(dec!(50_000), amount_ct("4.12"))
.fallback(amount_ct("3.37"))
.build()
.ok(),
2017..=2022 => RateLookup::builder()
.at_most(dec!(500), amount_ct("12.15"))
.at_most(dec!(2_000), amount_ct("8.01"))
.at_most(dec!(5_000), amount_ct("6.13"))
.at_most(dec!(10_000), amount_ct("5.37"))
.at_most(dec!(20_000), amount_ct("5.18"))
.at_most(dec!(50_000), amount_ct("4.16"))
.fallback(amount_ct("3.40"))
.build()
.ok(),
_ => None,
}
}
pub fn deponiegas_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(500), amount_ct("7.46"))
.at_most(dec!(5_000), amount_ct("5.17"))
.build()
.ok(),
_ => None,
}
}
pub fn klaergas_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(500), amount_ct("5.93"))
.at_most(dec!(5_000), amount_ct("5.17"))
.build()
.ok(),
_ => None,
}
}
pub fn grubengas_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2023..=2026 => RateLookup::builder()
.at_most(dec!(1_000), amount_ct("5.98"))
.at_most(dec!(5_000), amount_ct("3.81"))
.fallback(amount_ct("3.37"))
.build()
.ok(),
_ => None,
}
}
pub fn geothermie_lookup(eeg_year: i16) -> Option<RateLookup> {
match eeg_year {
2017..=2026 => RateLookup::builder()
.fallback(amount_ct("25.20"))
.build()
.ok(),
_ => None,
}
}
pub fn lookup_rate(
erzeugungsart: &str,
leistung_kwp: rust_decimal::Decimal,
eeg_year: i16,
) -> Result<Amount<5>, BillingError> {
let art = crate::ErzeugungsArt::from_db_str(erzeugungsart).map_err(|_| {
BillingError::InvalidInput {
reason: format!("unknown erzeugungsart {erzeugungsart:?}"),
}
})?;
lookup_rate_for(art, leistung_kwp, eeg_year)
}
pub fn lookup_rate_for(
art: crate::ErzeugungsArt,
leistung_kwp: rust_decimal::Decimal,
eeg_year: i16,
) -> Result<Amount<5>, BillingError> {
use crate::ErzeugungsArt as E;
let table = match art {
E::SolarAufdach
| E::SolarFreiflaeche
| E::SolarAgriPv
| E::SolarMieterstrom
| E::SolarStecker => (eeg_year >= 2023)
.then(|| solar_pv_ueberschuss_lookup(EEG2023_START))
.flatten(),
E::WindOnshore => wind_onshore_lookup(eeg_year),
E::WindOffshore => None,
E::Biomasse | E::Biogas => biomasse_lookup(eeg_year),
E::BiomassHolz | E::Biomethan => None,
E::Kwk => None,
E::Wasserkraft | E::Gezeiten => wasserkraft_lookup(eeg_year),
E::Geothermie => geothermie_lookup(eeg_year),
E::Deponiegas => deponiegas_lookup(eeg_year),
E::Klaergas => klaergas_lookup(eeg_year),
E::Grubengas => grubengas_lookup(eeg_year),
}
.ok_or(BillingError::InvalidInput {
reason: "no statutory rate table for this erzeugungsart/eeg_year combination — a KWK \
plant is priced by `crate::kwkg`, a tendered one by its award"
.to_owned(),
})?;
table.rate_for(leistung_kwp)
}
#[must_use]
pub fn jaehrliche_absenkung(
art: crate::ErzeugungsArt,
) -> Option<crate::degression::JaehrlicheAbsenkung> {
use crate::ErzeugungsArt as E;
use crate::degression::JaehrlicheAbsenkung as A;
match art {
E::Wasserkraft | E::Gezeiten => Some(A::WASSERKRAFT),
E::Deponiegas | E::Klaergas | E::Grubengas => Some(A::GASE),
E::Biomasse | E::Biogas => Some(A::BIOMASSE),
E::Geothermie => Some(A::GEOTHERMIE),
E::SolarAufdach
| E::SolarFreiflaeche
| E::SolarAgriPv
| E::SolarMieterstrom
| E::SolarStecker
| E::WindOnshore
| E::WindOffshore
| E::BiomassHolz
| E::Biomethan
| E::Kwk => None,
}
}
#[must_use]
pub fn aw_ct_bei_inbetriebnahme(
art: crate::ErzeugungsArt,
leistung_kw: Decimal,
inbetriebnahme: time::Date,
) -> Option<Decimal> {
use crate::ErzeugungsArt as E;
if matches!(
art,
E::SolarAufdach | E::SolarAgriPv | E::SolarMieterstrom | E::SolarStecker
) {
return solar_pv_ueberschuss_aw_ct(leistung_kw, inbetriebnahme);
}
if art == E::SolarFreiflaeche {
return solar_pv_freiflaeche_aw_ct(inbetriebnahme);
}
let eeg_year = if inbetriebnahme >= EEG2023_START {
2023
} else {
i16::try_from(inbetriebnahme.year()).ok()?
};
let startwert = lookup_rate_for(art, leistung_kw, eeg_year)
.ok()?
.into_decimal()
* Decimal::from(100u32);
Some(match jaehrliche_absenkung(art) {
Some(absenkung) => absenkung.anzulegender_wert(startwert, inbetriebnahme),
None => startwert,
})
}
pub fn sect53_deduction(art: crate::technology::ErzeugungsArt) -> rust_decimal::Decimal {
use crate::technology::ErzeugungsArt as A;
match art {
A::SolarAufdach
| A::SolarFreiflaeche
| A::SolarAgriPv
| A::SolarMieterstrom
| A::SolarStecker
| A::WindOnshore
| A::WindOffshore => dec!(0.4),
A::Biomasse
| A::BiomassHolz
| A::Biogas
| A::Biomethan
| A::Klaergas
| A::Grubengas
| A::Deponiegas
| A::Wasserkraft
| A::Geothermie
| A::Gezeiten => dec!(0.2),
A::Kwk => dec!(0),
}
}
#[cfg(test)]
mod statutory_rate_tests {
use super::*;
fn assert_ladder(table: &RateLookup, tiers: &[(&str, &str)], what: &str) {
for (kw, ct) in tiers {
let kw: Decimal = kw.parse().expect("test literal");
let expected = amount_ct(ct);
assert_eq!(
table
.rate_for(kw)
.unwrap_or_else(|e| panic!("{what} at {kw} kW: {e}")),
expected,
"{what}: {kw} kW must pay {ct} ct/kWh",
);
}
}
#[test]
fn wasserkraft_pays_the_seven_tiers_of_sect40() {
let t = wasserkraft_lookup(2023).expect("EEG 2023 table");
assert_ladder(
&t,
&[
("500", "12.03"),
("2000", "7.93"),
("5000", "6.07"),
("10000", "5.32"),
("20000", "5.13"),
("50000", "4.12"),
("60000", "3.37"),
],
"§ 40 Abs. 1 EEG 2023",
);
let t = wasserkraft_lookup(2021).expect("EEG 2021 table");
assert_ladder(
&t,
&[("500", "12.15"), ("2000", "8.01"), ("60000", "3.40")],
"§ 40 Abs. 1 EEG 2021",
);
}
#[test]
fn each_gas_pays_its_own_sect41_ladder() {
assert_ladder(
&deponiegas_lookup(2023).expect("table"),
&[("500", "7.46"), ("3000", "5.17")],
"§ 41 Abs. 1 EEG 2023 (Deponiegas)",
);
assert_ladder(
&klaergas_lookup(2023).expect("table"),
&[("500", "5.93"), ("3000", "5.17")],
"§ 41 Abs. 2 EEG 2023 (Klärgas)",
);
assert_ladder(
&grubengas_lookup(2023).expect("table"),
&[("1000", "5.98"), ("5000", "3.81"), ("9000", "3.37")],
"§ 41 Abs. 3 EEG 2023 (Grubengas)",
);
}
#[test]
fn biomasse_pays_one_statutory_tier_and_refuses_above_it() {
let t = biomasse_lookup(2023).expect("table");
assert_ladder(&t, &[("150", "12.67")], "§ 42 Satz 1 EEG 2023");
assert!(
t.rate_for(dec!(600)).is_err(),
"above 150 kW the value comes from a tender, not from this table",
);
}
#[test]
fn bioabfall_and_guelle_are_their_own_claims() {
assert_ladder(
&bioabfall_lookup(2023).expect("table"),
&[("500", "14.16"), ("20000", "12.41")],
"§ 43 Abs. 1 EEG 2023",
);
assert_ladder(
&guelle_lookup(2023).expect("table"),
&[("75", "22.00"), ("150", "19.00")],
"§ 44 Abs. 1 EEG 2023",
);
}
#[test]
fn geothermie_is_flat() {
let t = geothermie_lookup(2023).expect("table");
assert_ladder(
&t,
&[("100", "25.20"), ("5000", "25.20")],
"§ 45 Abs. 1 EEG 2023",
);
}
#[test]
fn wind_onshore_has_no_statutory_rate() {
for year in [2021, 2023, 2026] {
assert!(wind_onshore_lookup(year).is_none(), "year {year}");
}
assert!(
lookup_rate_for(crate::ErzeugungsArt::WindOnshore, dec!(700), 2023).is_err(),
"a wind plant is settled from its award, not from a table",
);
}
#[test]
fn the_statutory_absenkungen_are_applied_to_the_startwerte() {
use crate::ErzeugungsArt as E;
use time::macros::date;
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Wasserkraft, dec!(300), date!(2026 - 03 - 01)),
Some(dec!(11.85))
);
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Klaergas, dec!(400), date!(2024 - 06 - 01)),
Some(dec!(5.84))
);
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Biomasse, dec!(120), date!(2024 - 05 - 01)),
Some(dec!(12.67))
);
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Biomasse, dec!(120), date!(2024 - 07 - 01)),
Some(dec!(12.61))
);
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Geothermie, dec!(5000), date!(2025 - 06 - 01)),
Some(dec!(24.95))
);
assert_eq!(
aw_ct_bei_inbetriebnahme(E::Wasserkraft, dec!(300), date!(2023 - 06 - 01)),
Some(dec!(12.03))
);
}
#[test]
fn only_the_statutory_ladders_carry_an_annual_absenkung() {
use crate::ErzeugungsArt as E;
for art in [
E::SolarAufdach,
E::SolarFreiflaeche,
E::WindOnshore,
E::WindOffshore,
E::Biomethan,
E::BiomassHolz,
E::Kwk,
] {
assert!(jaehrliche_absenkung(art).is_none(), "{art:?}");
}
for art in [E::Wasserkraft, E::Gezeiten, E::Grubengas, E::Geothermie] {
assert!(jaehrliche_absenkung(art).is_some(), "{art:?}");
}
}
#[test]
fn tidal_settles_as_wasserkraft() {
use crate::ErzeugungsArt as E;
let tidal = lookup_rate_for(E::Gezeiten, dec!(3000), 2023).expect("§ 40 covers it");
let hydro = lookup_rate_for(E::Wasserkraft, dec!(3000), 2023).expect("§ 40");
assert_eq!(tidal, hydro);
assert_eq!(tidal, amount_ct("6.07"));
}
}