use rust_decimal::{Decimal, dec};
use time::{Date, Month, OffsetDateTime, UtcOffset};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::MeterInterval;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
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)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
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)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
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",
}
}
#[must_use]
pub fn is_available_on(self, at: Date) -> bool {
!matches!(self, Self::PauschalAbrechnung) || at <= PAUSCHAL_BESTANDSSCHUTZ_ENDE
}
}
crate::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),
}
}
#[must_use]
pub const fn leistungslimitierung_referenzprofil_kw(
_richtung: Redispatchrichtung,
vorgabe_kw: Decimal,
) -> Decimal {
vorgabe_kw
}
#[must_use]
pub fn anlagenfaktor(at: OffsetDateTime) -> Decimal {
let mez = at.to_offset(UtcOffset::from_hms(1, 0, 0).unwrap_or(UtcOffset::UTC));
let minutes = i32::from(mez.hour()) * 60 + i32::from(mez.minute());
let month = mez.month() as u8;
if (3..=10).contains(&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);
#[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,
) -> Decimal {
let theoretisch = min_of([Some(p0_kw), Some(p_inst_kw), p_mba_kw, p_bean_kw]);
((theoretisch - p_lim_kw) * VIERTELSTUNDE_H).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,
) -> Decimal {
let theoretisch = min_of([
Some(anlagenfaktor * p_inst_kw),
Some(p_wr_kw),
p_mba_kw,
p_bean_kw,
]);
((theoretisch - p_lim_kw) * VIERTELSTUNDE_H).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,
) -> Decimal {
match richtung {
Redispatchrichtung::Positiv => {
let limit = min_of([Some(p_lim_kw), p_bean_kw]);
((p0_kw - limit) * VIERTELSTUNDE_H).min(Decimal::ZERO)
}
Redispatchrichtung::Negativ => {
let theoretisch = min_of([Some(p0_kw), p_bean_kw]);
((theoretisch - p_lim_kw) * VIERTELSTUNDE_H).max(Decimal::ZERO)
}
}
}
#[must_use]
pub fn spitz_nicht_fluktuierend_kwh(
richtung: Redispatchrichtung,
p_plan_kw: Decimal,
p_lim_kw: Decimal,
) -> Decimal {
let raw = (p_plan_kw - p_lim_kw) * VIERTELSTUNDE_H;
match richtung {
Redispatchrichtung::Positiv => raw.min(Decimal::ZERO),
Redispatchrichtung::Negativ => raw.max(Decimal::ZERO),
}
}
fn min_of<const N: usize>(terms: [Option<Decimal>; N]) -> Decimal {
terms
.into_iter()
.flatten()
.reduce(Decimal::min)
.unwrap_or(Decimal::ZERO)
}
#[must_use]
pub fn p0_kw(series: &[MeterInterval], massnahme_beginn: OffsetDateTime) -> Option<&MeterInterval> {
series
.iter()
.filter(|iv| iv.to <= massnahme_beginn && iv.quality == crate::QualityFlag::Measured)
.max_by_key(|iv| iv.to)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
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),
);
}
}
}
#[test]
fn a_curtailed_turbine_loses_the_difference_over_the_quarter_hour() {
let w = pauschal_wind_kwh(dec!(900), dec!(1000), None, None, dec!(300));
assert_eq!(w, dec!(150.00)); }
#[test]
fn the_installed_power_bounds_the_carried_forward_value() {
let w = pauschal_wind_kwh(dec!(1200), dec!(1000), None, None, dec!(300));
assert_eq!(w, dec!(175.00)); }
#[test]
fn the_lowest_of_the_bounds_is_the_one_that_counts() {
let w = pauschal_wind_kwh(
dec!(900),
dec!(1000),
Some(dec!(600)),
Some(dec!(700)),
dec!(300),
);
assert_eq!(w, dec!(75.00)); }
#[test]
fn a_limit_above_the_theoretical_power_is_no_ausfallarbeit() {
assert_eq!(
pauschal_wind_kwh(dec!(300), dec!(1000), None, None, dec!(900)),
Decimal::ZERO
);
}
#[test]
fn the_solar_variant_is_the_anlagenfaktor_bounded_by_the_inverter() {
let w = pauschal_solar_kwh(dec!(0.6189), dec!(1000), dec!(700), None, None, dec!(100));
assert_eq!(w, dec!(129.725000));
let clipped =
pauschal_solar_kwh(dec!(0.6189), dec!(1000), dec!(500), None, None, dec!(100));
assert_eq!(clipped, 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)),
dec!(75.00),
);
assert_eq!(
pauschal_nicht_fluktuierend_kwh(Positiv, dec!(900), Some(dec!(600)), dec!(1500)),
Decimal::ZERO,
);
assert_eq!(
pauschal_nicht_fluktuierend_kwh(Positiv, dec!(300), None, dec!(900)),
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)),
dec!(-100.00),
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Negativ, dec!(600), dec!(200)),
dec!(100.00),
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Positiv, dec!(600), dec!(200)),
Decimal::ZERO,
);
assert_eq!(
spitz_nicht_fluktuierend_kwh(Negativ, dec!(200), dec!(600)),
Decimal::ZERO,
);
}
#[test]
fn the_anlagenfaktor_reproduces_the_published_table() {
let sommer = |h: i64, m: i64| {
anlagenfaktor(datetime!(2026-06-15 0:00 UTC) + time::Duration::minutes(h * 60 + m))
};
assert_eq!(sommer(5, 0), dec!(0.2456)); assert_eq!(sommer(8, 0), dec!(0.6189)); assert_eq!(sommer(14, 0), dec!(0.2456)); assert_eq!(sommer(18, 0), dec!(0.0000)); assert_eq!(sommer(4, 59), dec!(0.0000));
let winter = |h: i64, m: i64| {
anlagenfaktor(datetime!(2026-01-15 0:00 UTC) + time::Duration::minutes(h * 60 + m))
};
assert_eq!(winter(8, 0), dec!(0.2796)); assert_eq!(winter(9, 0), dec!(0.5030)); assert_eq!(winter(13, 0), dec!(0.2796)); assert_eq!(winter(15, 44), dec!(0.2796)); assert_eq!(winter(15, 45), dec!(0.0000)); }
#[test]
fn the_table_is_mez_not_local_time() {
assert_eq!(anlagenfaktor(datetime!(2026-06-15 6:30 UTC)), dec!(0.2456));
assert_eq!(anlagenfaktor(datetime!(2026-06-15 8:30 UTC)), dec!(0.6189));
}
#[test]
fn the_seasons_switch_on_the_first_of_march_and_november() {
assert_eq!(anlagenfaktor(datetime!(2026-02-28 9:00 UTC)), dec!(0.5030)); assert_eq!(anlagenfaktor(datetime!(2026-03-01 9:00 UTC)), dec!(0.6189)); assert_eq!(anlagenfaktor(datetime!(2026-10-31 9:00 UTC)), dec!(0.6189)); assert_eq!(anlagenfaktor(datetime!(2026-11-01 9:00 UTC)), dec!(0.5030)); assert_eq!(anlagenfaktor(datetime!(2028-02-29 9:00 UTC)), dec!(0.5030)); }
fn iv(from: OffsetDateTime, kwh: Decimal, quality: QualityFlag) -> MeterInterval {
MeterInterval {
from,
to: from + time::Duration::minutes(15),
value: kwh,
quality,
obis_code: None,
}
}
#[test]
fn p0_is_the_last_billable_quarter_hour_wholly_before_the_measure() {
let base = datetime!(2026-06-01 10:00 UTC);
let series = vec![
iv(base, dec!(100), QualityFlag::Measured),
iv(
base + time::Duration::minutes(15),
dec!(120),
QualityFlag::Measured,
),
iv(
base + time::Duration::minutes(30),
dec!(130),
QualityFlag::Substituted,
),
iv(
base + time::Duration::minutes(45),
dec!(40),
QualityFlag::Measured,
),
];
let start = base + time::Duration::minutes(45);
let p0 = p0_kw(&series, start).expect("one qualifies");
assert_eq!(p0.value, dec!(120));
assert_eq!(p0.demand_kw(), Some(dec!(480))); }
#[test]
fn p0_refuses_when_nothing_qualifies() {
let base = datetime!(2026-06-01 10:00 UTC);
let only_faulty = vec![iv(base, dec!(100), QualityFlag::Faulty)];
assert!(p0_kw(&only_faulty, base + time::Duration::minutes(15)).is_none());
assert!(p0_kw(&[], base).is_none());
for quality in [
QualityFlag::Substituted,
QualityFlag::Estimated,
QualityFlag::Calculated,
] {
assert!(quality.is_billable(), "{quality} is billable");
let series = vec![iv(base, dec!(100), quality)];
assert!(
p0_kw(&series, base + time::Duration::minutes(15)).is_none(),
"{quality} must not be carried forward as P_0"
);
}
}
#[test]
fn the_pauschal_variant_expires_and_the_others_do_not() {
let p = Abrechnungsvariante::PauschalAbrechnung;
assert!(p.is_available_on(date!(2028 - 12 - 31)));
assert!(!p.is_available_on(date!(2029 - 01 - 01)));
assert_eq!(PAUSCHAL_BESTANDSSCHUTZ_ENDE, date!(2028 - 12 - 31));
for other in [
Abrechnungsvariante::Spitzabrechnung,
Abrechnungsvariante::VereinfachteSpitzabrechnung,
] {
assert!(other.is_available_on(date!(2029 - 01 - 01)));
}
}
}