use rust_decimal::{Decimal, dec};
use time::{Date, Month, OffsetDateTime, macros::offset};
use crate::precision::{AUSFALLARBEIT_DP, AUSFALLARBEIT_STRATEGY};
use crate::series::interval::{MeterInterval, QualityFlag};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Redispatchrichtung {
Positiv,
Negativ,
}
impl Redispatchrichtung {
pub const ALL: [Self; 2] = [Self::Positiv, Self::Negativ];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Positiv => "POSITIV",
Self::Negativ => "NEGATIV",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Redispatchfall {
Aufforderung,
Duldung,
}
impl Redispatchfall {
pub const ALL: [Self; 2] = [Self::Aufforderung, Self::Duldung];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Aufforderung => "AUFFORDERUNG",
Self::Duldung => "DULDUNG",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Abrechnungsvariante {
Spitzabrechnung,
VereinfachteSpitzabrechnung,
PauschalAbrechnung,
}
impl Abrechnungsvariante {
pub const ALL: [Self; 3] = [
Self::Spitzabrechnung,
Self::VereinfachteSpitzabrechnung,
Self::PauschalAbrechnung,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Spitzabrechnung => "SPITZABRECHNUNG",
Self::VereinfachteSpitzabrechnung => "VEREINFACHTE_SPITZABRECHNUNG",
Self::PauschalAbrechnung => "PAUSCHAL_ABRECHNUNG",
}
}
}
crate::ids::codes::string_codes! {
Redispatchrichtung;
Redispatchfall;
Abrechnungsvariante;
}
pub const PAUSCHAL_BESTANDSSCHUTZ_ENDE: Date =
match Date::from_calendar_date(2028, Month::December, 31) {
Ok(d) => d,
Err(_) => unreachable!(),
};
#[must_use]
pub fn leistungslimitierung_kw(
fall: Redispatchfall,
richtung: Redispatchrichtung,
p_ist_kw: Decimal,
vorgabe_kw: Decimal,
) -> Decimal {
match (fall, richtung) {
(Redispatchfall::Duldung, _) => p_ist_kw,
(Redispatchfall::Aufforderung, Redispatchrichtung::Positiv) => p_ist_kw.min(vorgabe_kw),
(Redispatchfall::Aufforderung, Redispatchrichtung::Negativ) => p_ist_kw.max(vorgabe_kw),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Viertelstunde(OffsetDateTime);
impl Viertelstunde {
#[must_use]
pub fn new(start: OffsetDateTime) -> Option<Self> {
let utc = start.to_offset(offset!(UTC));
(utc.minute().is_multiple_of(15) && utc.second() == 0 && utc.nanosecond() == 0)
.then_some(Self(start))
}
#[must_use]
pub const fn start(self) -> OffsetDateTime {
self.0
}
}
#[must_use]
pub fn anlagenfaktor(viertelstunde: Viertelstunde) -> Decimal {
let mez = viertelstunde.start().to_offset(offset!(+1));
let minutes = u16::from(mez.hour()) * 60 + u16::from(mez.minute());
if (3..=10).contains(&u8::from(mez.month())) {
match minutes {
m if (6 * 60..9 * 60).contains(&m) => dec!(0.2456),
m if (9 * 60..15 * 60).contains(&m) => dec!(0.6189),
m if (15 * 60..19 * 60).contains(&m) => dec!(0.2456),
_ => dec!(0.0000),
}
} else {
match minutes {
m if (9 * 60..10 * 60).contains(&m) => dec!(0.2796),
m if (10 * 60..14 * 60).contains(&m) => dec!(0.5030),
m if (14 * 60..16 * 60 + 45).contains(&m) => dec!(0.2796),
_ => dec!(0.0000),
}
}
}
const VIERTELSTUNDE_H: Decimal = dec!(0.25);
fn min_of<const N: usize>(first: Decimal, rest: [Option<Decimal>; N]) -> Decimal {
rest.into_iter().flatten().fold(first, Decimal::min)
}
fn work(theoretisch: Decimal, p_lim_kw: Decimal) -> Option<Decimal> {
theoretisch
.checked_sub(p_lim_kw)?
.checked_mul(VIERTELSTUNDE_H)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Fluktuierend {
Wind {
p_theo_kw: Decimal,
p_vz_ist_kw: Decimal,
p_vz_theo_kw: Decimal,
},
Solar {
g_i_kw_m2: Decimal,
p_vz_ist_kw: Decimal,
g_vz_kw_m2: Decimal,
p_wr_kw: Decimal,
},
}
#[must_use]
pub fn spitz_fluktuierend_kwh(
theoretisch: Fluktuierend,
nennleistung_kw: Decimal,
p_mba_kw: Option<Decimal>,
p_bean_kw: Option<Decimal>,
p_lim_kw: Decimal,
) -> Option<Decimal> {
let (product, p_wr_kw) = match theoretisch {
Fluktuierend::Wind {
p_theo_kw,
p_vz_ist_kw,
p_vz_theo_kw,
} => {
if p_vz_theo_kw <= Decimal::ZERO {
return None;
}
(
p_vz_ist_kw
.checked_mul(p_theo_kw)?
.checked_div(p_vz_theo_kw)?,
None,
)
}
Fluktuierend::Solar {
g_i_kw_m2,
p_vz_ist_kw,
g_vz_kw_m2,
p_wr_kw,
} => {
if g_vz_kw_m2 <= Decimal::ZERO {
return None;
}
(
p_vz_ist_kw
.checked_mul(g_i_kw_m2)?
.checked_div(g_vz_kw_m2)?,
Some(p_wr_kw),
)
}
};
let capped = product.min(nennleistung_kw);
let theoretisch = min_of(capped, [p_wr_kw, p_mba_kw, p_bean_kw]);
Some(
work(theoretisch, p_lim_kw)?
.max(Decimal::ZERO)
.round_dp_with_strategy(AUSFALLARBEIT_DP, AUSFALLARBEIT_STRATEGY),
)
}
#[must_use]
pub fn pauschal_wind_kwh(
p0_kw: Decimal,
p_inst_kw: Decimal,
p_mba_kw: Option<Decimal>,
p_bean_kw: Option<Decimal>,
p_lim_kw: Decimal,
) -> Option<Decimal> {
let theoretisch = min_of(p0_kw, [Some(p_inst_kw), p_mba_kw, p_bean_kw]);
Some(work(theoretisch, p_lim_kw)?.max(Decimal::ZERO))
}
#[must_use]
pub fn pauschal_solar_kwh(
anlagenfaktor: Decimal,
p_inst_kw: Decimal,
p_wr_kw: Decimal,
p_mba_kw: Option<Decimal>,
p_bean_kw: Option<Decimal>,
p_lim_kw: Decimal,
) -> Option<Decimal> {
let theoretisch = min_of(
anlagenfaktor.checked_mul(p_inst_kw)?,
[Some(p_wr_kw), p_mba_kw, p_bean_kw],
);
Some(work(theoretisch, p_lim_kw)?.max(Decimal::ZERO))
}
#[must_use]
pub fn pauschal_nicht_fluktuierend_kwh(
richtung: Redispatchrichtung,
p0_kw: Decimal,
p_bean_kw: Option<Decimal>,
p_lim_kw: Decimal,
) -> Option<Decimal> {
Some(match richtung {
Redispatchrichtung::Positiv => {
work(p0_kw, min_of(p_lim_kw, [p_bean_kw]))?.min(Decimal::ZERO)
}
Redispatchrichtung::Negativ => {
work(min_of(p0_kw, [p_bean_kw]), p_lim_kw)?.max(Decimal::ZERO)
}
})
}
#[must_use]
pub fn spitz_nicht_fluktuierend_kwh(
richtung: Redispatchrichtung,
p_plan_kw: Decimal,
p_lim_kw: Decimal,
) -> Option<Decimal> {
let raw = work(p_plan_kw, p_lim_kw)?;
Some(match richtung {
Redispatchrichtung::Positiv => raw.min(Decimal::ZERO),
Redispatchrichtung::Negativ => raw.max(Decimal::ZERO),
})
}
#[must_use]
pub fn p0_kw(series: &[MeterInterval], massnahme_beginn: OffsetDateTime) -> Option<Decimal> {
series
.iter()
.filter(|iv| {
iv.to() <= massnahme_beginn
&& iv.quality() == QualityFlag::Measured
&& iv.duration() == time::Duration::minutes(15)
})
.max_by_key(|iv| iv.to())?
.demand_kw()
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
#[test]
fn the_aufforderungsfall_takes_the_min_going_up_and_the_max_coming_down() {
use Redispatchfall::Aufforderung as A;
use Redispatchrichtung::{Negativ, Positiv};
assert_eq!(
leistungslimitierung_kw(A, Positiv, dec!(80), dec!(300)),
dec!(80)
);
assert_eq!(
leistungslimitierung_kw(A, Positiv, dec!(400), dec!(300)),
dec!(300)
);
assert_eq!(
leistungslimitierung_kw(A, Negativ, dec!(420), dec!(300)),
dec!(420)
);
assert_eq!(
leistungslimitierung_kw(A, Negativ, dec!(200), dec!(300)),
dec!(300)
);
}
#[test]
fn the_duldungsfall_is_what_actually_happened() {
for richtung in Redispatchrichtung::ALL {
for vorgabe in [dec!(0), dec!(300), dec!(9999)] {
assert_eq!(
leistungslimitierung_kw(Redispatchfall::Duldung, richtung, dec!(420), vorgabe),
dec!(420),
);
}
}
}
fn wind(p_theo: Decimal, ist: Decimal, theo: Decimal) -> Fluktuierend {
Fluktuierend::Wind {
p_theo_kw: p_theo,
p_vz_ist_kw: ist,
p_vz_theo_kw: theo,
}
}
#[test]
fn the_wind_spitzabrechnung_applies_kf_the_bounds_and_the_cap() {
let w = wind(dec!(2000), dec!(1800), dec!(2000));
assert_eq!(
spitz_fluktuierend_kwh(w, dec!(3000), None, None, dec!(600)),
Some(dec!(300.000))
);
assert_eq!(
spitz_fluktuierend_kwh(w, dec!(3000), Some(dec!(1400)), Some(dec!(1600)), dec!(600)),
Some(dec!(200.000))
);
assert_eq!(
spitz_fluktuierend_kwh(w, dec!(3000), Some(dec!(1400)), Some(dec!(1000)), dec!(600)),
Some(dec!(100.000))
);
let over = wind(dec!(2400), dec!(1500), dec!(1000));
assert_eq!(
spitz_fluktuierend_kwh(over, dec!(3000), None, None, dec!(600)),
Some(dec!(600.000))
);
assert_eq!(
spitz_fluktuierend_kwh(w, dec!(3000), None, None, dec!(2500)),
Some(dec!(0.000))
);
assert_eq!(
spitz_fluktuierend_kwh(
wind(dec!(1), dec!(1), dec!(0)),
dec!(3),
None,
None,
dec!(0)
),
None
);
}
#[test]
fn a_non_terminating_kf_is_cut_once_to_watt_hours() {
let w = wind(dec!(2000), dec!(1000), dec!(3000));
assert_eq!(
spitz_fluktuierend_kwh(w, dec!(5000), None, None, dec!(0)),
Some(dec!(166.667))
);
}
#[test]
fn the_solar_spitzabrechnung_scales_the_irradiance_and_respects_the_inverter() {
let solar = |p_wr| Fluktuierend::Solar {
g_i_kw_m2: dec!(0.8),
p_vz_ist_kw: dec!(600),
g_vz_kw_m2: dec!(0.6),
p_wr_kw: p_wr,
};
assert_eq!(
spitz_fluktuierend_kwh(solar(dec!(1000)), dec!(1000), None, None, dec!(200)),
Some(dec!(150.000))
);
assert_eq!(
spitz_fluktuierend_kwh(solar(dec!(700)), dec!(1000), None, None, dec!(200)),
Some(dec!(125.000))
);
assert_eq!(
spitz_fluktuierend_kwh(solar(dec!(1000)), dec!(750), None, None, dec!(200)),
Some(dec!(137.500))
);
let dark = Fluktuierend::Solar {
g_i_kw_m2: dec!(0.8),
p_vz_ist_kw: dec!(600),
g_vz_kw_m2: dec!(0),
p_wr_kw: dec!(700),
};
assert_eq!(
spitz_fluktuierend_kwh(dark, dec!(1000), None, None, dec!(0)),
None
);
}
#[test]
fn the_wind_pauschal_takes_the_lowest_bound() {
assert_eq!(
pauschal_wind_kwh(dec!(900), dec!(1000), None, None, dec!(300)),
Some(dec!(150.00))
);
assert_eq!(
pauschal_wind_kwh(dec!(1200), dec!(1000), None, None, dec!(300)),
Some(dec!(175.00))
);
assert_eq!(
pauschal_wind_kwh(
dec!(900),
dec!(1000),
Some(dec!(600)),
Some(dec!(700)),
dec!(300)
),
Some(dec!(75.00))
);
assert_eq!(
pauschal_wind_kwh(dec!(300), dec!(1000), None, None, dec!(900)),
Some(Decimal::ZERO)
);
}
#[test]
fn the_solar_pauschal_is_the_anlagenfaktor_bounded_by_the_inverter() {
assert_eq!(
pauschal_solar_kwh(dec!(0.6189), dec!(1000), dec!(700), None, None, dec!(100)),
Some(dec!(129.725000))
);
assert_eq!(
pauschal_solar_kwh(dec!(0.6189), dec!(1000), dec!(500), None, None, dec!(100)),
Some(dec!(100.00))
);
}
#[test]
fn the_nichtbeanspruchbarkeit_changes_sides_with_the_direction() {
use Redispatchrichtung::{Negativ, Positiv};
assert_eq!(
pauschal_nicht_fluktuierend_kwh(Negativ, dec!(900), Some(dec!(600)), dec!(300)),
Some(dec!(75.00)),
);
assert_eq!(
pauschal_nicht_fluktuierend_kwh(Positiv, dec!(900), Some(dec!(600)), dec!(1500)),
Some(Decimal::ZERO),
);
assert_eq!(
pauschal_nicht_fluktuierend_kwh(Positiv, dec!(300), None, dec!(900)),
Some(dec!(-150.00)),
);
}
#[test]
fn a_positive_redispatch_yields_negative_ausfallarbeit() {
use Redispatchrichtung::{Negativ, Positiv};
assert_eq!(
spitz_nicht_fluktuierend_kwh(Positiv, dec!(200), dec!(600)),
Some(dec!(-100.00))
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Negativ, dec!(600), dec!(200)),
Some(dec!(100.00))
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Positiv, dec!(600), dec!(200)),
Some(Decimal::ZERO)
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Negativ, dec!(200), dec!(600)),
Some(Decimal::ZERO)
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Negativ, Decimal::MAX, Decimal::MIN),
None,
"overflow is refused, not a panic"
);
}
fn q(t: OffsetDateTime) -> Viertelstunde {
Viertelstunde::new(t).unwrap()
}
#[test]
fn the_anlagenfaktor_reproduces_the_published_table() {
let sommer = |m: i64| {
anlagenfaktor(q(
datetime!(2026-06-15 0:00 UTC) + time::Duration::minutes(m)
))
};
assert_eq!(sommer(5 * 60), dec!(0.2456)); assert_eq!(sommer(8 * 60), dec!(0.6189)); assert_eq!(sommer(14 * 60), dec!(0.2456)); assert_eq!(sommer(18 * 60), dec!(0.0000)); assert_eq!(sommer(4 * 60 + 45), dec!(0.0000)); let winter = |m: i64| {
anlagenfaktor(q(
datetime!(2026-01-15 0:00 UTC) + time::Duration::minutes(m)
))
};
assert_eq!(winter(8 * 60), dec!(0.2796)); assert_eq!(winter(9 * 60), dec!(0.5030)); assert_eq!(winter(13 * 60), dec!(0.2796)); assert_eq!(winter(15 * 60 + 30), dec!(0.2796)); assert_eq!(winter(15 * 60 + 45), dec!(0.0000)); }
#[test]
fn the_table_is_mez_not_local_time() {
assert_eq!(
anlagenfaktor(q(datetime!(2026-06-15 6:30 UTC))),
dec!(0.2456)
);
assert_eq!(
anlagenfaktor(q(datetime!(2026-06-15 8:30 UTC))),
dec!(0.6189)
);
}
#[test]
fn the_seasons_switch_on_the_first_of_march_and_november_in_mez() {
assert_eq!(
anlagenfaktor(q(datetime!(2026-02-28 9:00 UTC))),
dec!(0.5030)
);
assert_eq!(
anlagenfaktor(q(datetime!(2026-03-01 9:00 UTC))),
dec!(0.6189)
);
assert_eq!(
anlagenfaktor(q(datetime!(2026-11-01 9:00 UTC))),
dec!(0.5030)
);
assert_eq!(
anlagenfaktor(q(datetime!(2028-02-29 9:00 UTC))),
dec!(0.5030)
);
}
#[test]
fn a_viertelstunde_starts_on_a_quarter_hour() {
assert!(Viertelstunde::new(datetime!(2026-06-15 8:45 UTC)).is_some());
assert!(Viertelstunde::new(datetime!(2026-06-15 8:50 UTC)).is_none());
assert!(Viertelstunde::new(datetime!(2026-06-15 8:45:01 UTC)).is_none());
assert!(Viertelstunde::new(datetime!(2026-06-15 10:45 +2)).is_some());
}
fn iv(from: OffsetDateTime, kwh: Decimal, quality: QualityFlag) -> MeterInterval {
MeterInterval::quarter_hour(from, kwh, quality).unwrap()
}
#[test]
fn p0_is_the_power_of_the_last_measured_quarter_hour_before_the_measure() {
let base = datetime!(2026-06-01 10:00 UTC);
let m = |n: i64| base + time::Duration::minutes(15 * n);
let series = vec![
iv(m(0), dec!(100), QualityFlag::Measured),
iv(m(1), dec!(120), QualityFlag::Measured),
iv(m(2), dec!(130), QualityFlag::Substituted),
iv(m(3), dec!(40), QualityFlag::Measured),
];
assert_eq!(p0_kw(&series, m(3)), Some(dec!(480)));
}
#[test]
fn p0_refuses_what_is_not_a_measured_quarter_hour() {
let base = datetime!(2026-06-01 10:00 UTC);
let after = base + time::Duration::hours(1);
assert_eq!(
p0_kw(&[iv(base, dec!(100), QualityFlag::Faulty)], after),
None
);
assert_eq!(p0_kw(&[], base), None);
for quality in [
QualityFlag::Substituted,
QualityFlag::Estimated,
QualityFlag::Calculated,
] {
assert_eq!(
p0_kw(&[iv(base, dec!(100), quality)], after),
None,
"{quality}"
);
}
let hour = MeterInterval::hour(base, dec!(100), QualityFlag::Measured).unwrap();
assert_eq!(p0_kw(&[hour], after), None);
}
#[test]
fn the_pauschal_bestandsschutz_ends_with_2028() {
assert_eq!(PAUSCHAL_BESTANDSSCHUTZ_ENDE, date!(2028 - 12 - 31));
}
}