use rust_decimal::Decimal;
use rust_decimal::prelude::Zero;
use time::{Date, Month, OffsetDateTime, Time};
pub const WETTERDATEN_LIEFERFRIST_WERKTAGE: u8 = 4;
pub const PAUSCHAL_WECHSEL_FRIST_MONATE: u8 = 3;
pub const VERGLEICHSZEITRAUM_MINDESTANTEIL: Decimal = Decimal::from_parts(1, 0, 0, false, 1);
pub const VERGLEICHSZEITRAUM_VIERTELSTUNDEN: usize = 4;
pub const WIND_BIN_MINDEST_WERTEPAARE: usize = 3;
pub const WIND_BIN_BREITE_MS: Decimal = Decimal::from_parts(5, 0, 0, false, 1);
const QUARTER_HOUR: Decimal = Decimal::from_parts(25, 0, 0, false, 2);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AusfallarbeitError {
#[error("unzulässiger Divisor: {0}")]
UnzulaessigerDivisor(&'static str),
#[error("Verlustfaktor {0} liegt nicht in ]0;1[")]
VerlustfaktorAusserhalb(Decimal),
#[error(
"kein zulässiger Vergleichszeitraum: keine {VERGLEICHSZEITRAUM_VIERTELSTUNDEN} \
zusammenhängenden, vollständig gemessenen Viertelstunden mit unbeschränkter \
Einspeisung ≥ 10 % der Nennleistung im Monat der Maßnahme"
)]
KeinVergleichszeitraum,
#[error(
"kein zulässiger Vergleichstag: kein Kalendertag im Monat der Maßnahme ohne \
Redispatch-Maßnahme mit mindestens einer Viertelstunde ≥ 10 % der Nennleistung \
ohne Nichtbeanspruchbarkeit oder marktbedingte Anpassung"
)]
KeinVergleichstag,
#[error("Wind-Bin unterbesetzt: {0} Wertepaare (< {WIND_BIN_MINDEST_WERTEPAARE})")]
BinUnterbesetzt(usize),
#[error("negativer Eingabewert: {0}")]
NegativerWert(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RedispatchRichtung {
Positiv,
Negativ,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "fall")]
pub enum Leistungslimitierung {
Aufforderung {
p_ist: Decimal,
vorgabe: Decimal,
},
Duldung {
p_ist: Decimal,
},
Referenzprofil {
vorgabe: Decimal,
},
}
impl Leistungslimitierung {
#[must_use]
pub fn wert(self, richtung: RedispatchRichtung) -> Decimal {
match self {
Self::Aufforderung { p_ist, vorgabe } => match richtung {
RedispatchRichtung::Positiv => p_ist.min(vorgabe),
RedispatchRichtung::Negativ => p_ist.max(vorgabe),
},
Self::Duldung { p_ist } => p_ist,
Self::Referenzprofil { vorgabe } => vorgabe,
}
}
}
pub fn malo_wert_auf_tr(
malo_wert: Decimal,
p_inst_kw: &[Decimal],
) -> Result<Vec<Decimal>, AusfallarbeitError> {
let summe: Decimal = p_inst_kw.iter().copied().sum();
if summe <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
}
Ok(p_inst_kw.iter().map(|p| malo_wert * p / summe).collect())
}
fn w_a(theo: Decimal, bounds: &[Option<Decimal>], p_lim: Decimal, negativ: bool) -> Decimal {
let mut m = theo;
for b in bounds.iter().copied().flatten() {
m = m.min(b);
}
let w = (m - p_lim) * QUARTER_HOUR;
if negativ {
w.max(Decimal::zero())
} else {
w.min(Decimal::zero())
}
}
pub fn korrekturfaktor(
p_vz_ist: Decimal,
p_vz_theo: Decimal,
) -> Result<Decimal, AusfallarbeitError> {
if p_vz_theo <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"));
}
Ok(p_vz_ist / p_vz_theo)
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct WindSpitzInput {
pub kf: Decimal,
pub p_theo: Decimal,
pub p_mba: Option<Decimal>,
pub p_bean: Option<Decimal>,
pub p_lim: Decimal,
pub p_nenn: Decimal,
}
#[must_use]
pub fn wind_spitz(input: &WindSpitzInput) -> Decimal {
let theo = (input.kf * input.p_theo).min(input.p_nenn);
w_a(theo, &[input.p_mba, input.p_bean], input.p_lim, true)
}
#[must_use]
pub fn wind_pauschal(
p_0: Decimal,
p_inst: Decimal,
p_mba: Option<Decimal>,
p_bean: Option<Decimal>,
p_lim: Decimal,
) -> Decimal {
w_a(p_0.min(p_inst), &[p_mba, p_bean], p_lim, true)
}
mod rfc3339_vec {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use time::OffsetDateTime;
#[derive(Serialize, Deserialize)]
struct One(#[serde(with = "time::serde::rfc3339")] OffsetDateTime);
pub(super) fn serialize<S: Serializer>(
value: &[OffsetDateTime],
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.collect_seq(value.iter().copied().map(One))
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<OffsetDateTime>, D::Error> {
Ok(Vec::<One>::deserialize(deserializer)?
.into_iter()
.map(|One(v)| v)
.collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VergleichsViertelstunde {
#[serde(with = "time::serde::rfc3339")]
pub beginn: OffsetDateTime,
pub p_ist_kw: Decimal,
pub p_theo_kw: Decimal,
pub vollstaendig_gemessen: bool,
pub unbeschraenkt: bool,
}
impl VergleichsViertelstunde {
fn zulaessig(&self, p_nenn_kw: Decimal) -> bool {
self.vollstaendig_gemessen
&& self.unbeschraenkt
&& self.p_ist_kw >= p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VergleichszeitraumLage {
Davor,
Danach,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Vergleichszeitraum {
pub p_vz_ist_kw: Decimal,
pub p_vz_theo_kw: Decimal,
pub lage: VergleichszeitraumLage,
#[serde(with = "rfc3339_vec")]
pub viertelstunden: Vec<OffsetDateTime>,
}
impl Vergleichszeitraum {
pub fn korrekturfaktor(&self) -> Result<Decimal, AusfallarbeitError> {
korrekturfaktor(self.p_vz_ist_kw, self.p_vz_theo_kw)
}
}
pub fn vergleichszeitraum(
kandidaten: &[VergleichsViertelstunde],
massnahme_beginn: OffsetDateTime,
massnahme_ende: OffsetDateTime,
p_nenn_kw: Decimal,
) -> Result<Vergleichszeitraum, AusfallarbeitError> {
let n = VERGLEICHSZEITRAUM_VIERTELSTUNDEN;
let viertelstunde = time::Duration::minutes(15);
let monat = (massnahme_beginn.year(), massnahme_beginn.month());
let mut best: Option<(
time::Duration,
VergleichszeitraumLage,
&[VergleichsViertelstunde],
)> = None;
for run in kandidaten.windows(n) {
if run
.windows(2)
.any(|pair| pair[1].beginn - pair[0].beginn != viertelstunde)
{
continue;
}
if !run.iter().all(|vs| vs.zulaessig(p_nenn_kw)) {
continue;
}
let ende = run[n - 1].beginn + viertelstunde;
let (abstand, lage) = if ende <= massnahme_beginn {
(massnahme_beginn - ende, VergleichszeitraumLage::Davor)
} else if run[0].beginn >= massnahme_ende {
(
run[0].beginn - massnahme_ende,
VergleichszeitraumLage::Danach,
)
} else {
continue;
};
if run
.iter()
.any(|vs| (vs.beginn.year(), vs.beginn.month()) != monat)
{
continue;
}
let better = match &best {
None => true,
Some((d, l, _)) => {
abstand < *d
|| (abstand == *d
&& *l == VergleichszeitraumLage::Danach
&& lage == VergleichszeitraumLage::Davor)
}
};
if better {
best = Some((abstand, lage, run));
}
}
let Some((_, lage, run)) = best else {
return Err(AusfallarbeitError::KeinVergleichszeitraum);
};
let teiler = Decimal::from(u64::try_from(n).unwrap_or(u64::MAX));
Ok(Vergleichszeitraum {
p_vz_ist_kw: run.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
p_vz_theo_kw: run.iter().map(|vs| vs.p_theo_kw).sum::<Decimal>() / teiler,
lage,
viertelstunden: run.iter().map(|vs| vs.beginn).collect(),
})
}
#[must_use]
pub fn wind_bin_index(windgeschwindigkeit_ms: Decimal) -> i64 {
let idx = (windgeschwindigkeit_ms / WIND_BIN_BREITE_MS)
.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
idx.try_into().unwrap_or(i64::MAX)
}
pub fn kf_lbin(
leistungswerte_kw: &[Decimal],
p_zert_lk: Decimal,
) -> Result<Decimal, AusfallarbeitError> {
let m = leistungswerte_kw.len();
if m < WIND_BIN_MINDEST_WERTEPAARE {
return Err(AusfallarbeitError::BinUnterbesetzt(m));
}
if p_zert_lk <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("P_zertLK"));
}
let mittel: Decimal = leistungswerte_kw.iter().copied().sum::<Decimal>()
/ Decimal::from(u64::try_from(m).unwrap_or(u64::MAX));
Ok((mittel / p_zert_lk).max(Decimal::zero()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KfLbinQuelle {
Monat,
Vormonat,
Folgemonat,
ZwoelfMonatsMittel,
Standard,
}
#[must_use]
pub fn kf_lbin_ersatzwert(
vormonat: Option<Decimal>,
folgemonat: Option<Decimal>,
zwoelf_monats_mittel: Option<Decimal>,
) -> (Decimal, KfLbinQuelle) {
if let Some(v) = vormonat {
(v, KfLbinQuelle::Vormonat)
} else if let Some(v) = folgemonat {
(v, KfLbinQuelle::Folgemonat)
} else if let Some(v) = zwoelf_monats_mittel {
(v, KfLbinQuelle::ZwoelfMonatsMittel)
} else {
(Decimal::ONE, KfLbinQuelle::Standard)
}
}
pub fn verlustfaktor(
e_einsp_kwh: Decimal,
summe_e_wea_kwh: Decimal,
) -> Result<Decimal, AusfallarbeitError> {
if summe_e_wea_kwh <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ E_WEA"));
}
let kf_v = e_einsp_kwh / summe_e_wea_kwh;
if kf_v <= Decimal::zero() || kf_v >= Decimal::ONE {
return Err(AusfallarbeitError::VerlustfaktorAusserhalb(kf_v));
}
Ok(kf_v)
}
#[must_use]
pub fn kf_bin(kf_lbin: Decimal, kf_v: Decimal) -> Decimal {
kf_lbin * kf_v
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct SolarSpitzInput {
pub p_vz_ist: Decimal,
pub g_vz: Decimal,
pub g_i: Decimal,
pub p_wr: Decimal,
pub p_mba: Option<Decimal>,
pub p_bean: Option<Decimal>,
pub p_lim: Decimal,
pub p_nenn: Decimal,
}
pub fn solar_spitz(input: &SolarSpitzInput) -> Result<Decimal, AusfallarbeitError> {
if input.g_vz <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
}
let theo = (input.p_vz_ist / input.g_vz * input.g_i).min(input.p_nenn);
Ok(w_a(
theo,
&[Some(input.p_wr), input.p_mba, input.p_bean],
input.p_lim,
true,
))
}
#[must_use]
pub fn anlagenfaktor(datum: Date, zeit: Time) -> Decimal {
let sommer = matches!(
datum.month(),
Month::March
| Month::April
| Month::May
| Month::June
| Month::July
| Month::August
| Month::September
| Month::October
);
let minuten = i32::from(zeit.hour()) * 60 + i32::from(zeit.minute());
let af = |zehntausendstel: i64| Decimal::new(zehntausendstel, 4);
if sommer {
match minuten {
m if (360..540).contains(&m) => af(2456), m if (540..900).contains(&m) => af(6189), m if (900..1140).contains(&m) => af(2456), _ => Decimal::zero(), }
} else {
match minuten {
m if (540..600).contains(&m) => af(2796), m if (600..840).contains(&m) => af(5030), m if (840..1005).contains(&m) => af(2796), _ => Decimal::zero(), }
}
}
#[must_use]
pub fn solar_pauschal(
af: Decimal,
p_inst_module: Decimal,
p_wr: Decimal,
p_mba: Option<Decimal>,
p_bean: Option<Decimal>,
p_lim: Decimal,
) -> Decimal {
w_a(
af * p_inst_module,
&[Some(p_wr), p_mba, p_bean],
p_lim,
true,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VergleichstagViertelstunde {
#[serde(with = "time::serde::rfc3339")]
pub beginn: OffsetDateTime,
pub p_ist_kw: Decimal,
pub einstrahlung_kw_m2: Decimal,
pub nichtbeanspruchbar_oder_mba: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Vergleichstag {
pub tag: Date,
pub p_vz_ist_kw: Decimal,
pub g_vz_kw_m2: Decimal,
pub lage: VergleichszeitraumLage,
pub viertelstunden: usize,
}
pub fn solar_vergleichstag(
kandidaten: &[VergleichstagViertelstunde],
massnahme_tag: Date,
tage_mit_massnahme: &[Date],
p_nenn_kw: Decimal,
) -> Result<Vergleichstag, AusfallarbeitError> {
let schwelle = p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL;
let mut best: Option<(i64, VergleichszeitraumLage, Date)> = None;
let mut tage: Vec<Date> = kandidaten.iter().map(|vs| vs.beginn.date()).collect();
tage.sort_unstable();
tage.dedup();
for tag in tage {
if tag == massnahme_tag || tage_mit_massnahme.contains(&tag) {
continue;
}
if (tag.year(), tag.month()) != (massnahme_tag.year(), massnahme_tag.month()) {
continue;
}
if !kandidaten
.iter()
.any(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
{
continue;
}
let abstand = (tag - massnahme_tag).whole_days();
let lage = if abstand < 0 {
VergleichszeitraumLage::Davor
} else {
VergleichszeitraumLage::Danach
};
let entfernung = abstand.abs();
let better = match &best {
None => true,
Some((d, l, _)) => {
entfernung < *d
|| (entfernung == *d
&& *l == VergleichszeitraumLage::Danach
&& lage == VergleichszeitraumLage::Davor)
}
};
if better {
best = Some((entfernung, lage, tag));
}
}
let Some((_, lage, tag)) = best else {
return Err(AusfallarbeitError::KeinVergleichstag);
};
let admitted: Vec<&VergleichstagViertelstunde> = kandidaten
.iter()
.filter(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
.collect();
let teiler = Decimal::from(u64::try_from(admitted.len()).unwrap_or(u64::MAX));
let g_vz = admitted
.iter()
.map(|vs| vs.einstrahlung_kw_m2)
.sum::<Decimal>()
/ teiler;
if g_vz <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
}
Ok(Vergleichstag {
tag,
p_vz_ist_kw: admitted.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
g_vz_kw_m2: g_vz,
lage,
viertelstunden: admitted.len(),
})
}
fn zulaessig(vs: &VergleichstagViertelstunde, schwelle_kw: Decimal) -> bool {
!vs.nichtbeanspruchbar_oder_mba && vs.p_ist_kw >= schwelle_kw
}
#[must_use]
pub fn nichtfluktuierend_spitz(
richtung: RedispatchRichtung,
p_plan: Decimal,
p_lim: Decimal,
) -> Decimal {
let w = (p_plan - p_lim) * QUARTER_HOUR;
match richtung {
RedispatchRichtung::Positiv => w.min(Decimal::zero()),
RedispatchRichtung::Negativ => w.max(Decimal::zero()),
}
}
#[must_use]
pub fn nichtfluktuierend_pauschal(
richtung: RedispatchRichtung,
p_0: Decimal,
p_bean: Option<Decimal>,
p_lim: Decimal,
) -> Decimal {
match richtung {
RedispatchRichtung::Positiv => {
let grenze = p_bean.map_or(p_lim, |b| p_lim.min(b));
((p_0 - grenze) * QUARTER_HOUR).min(Decimal::zero())
}
RedispatchRichtung::Negativ => {
let basis = p_bean.map_or(p_0, |b| p_0.min(b));
((basis - p_lim) * QUARTER_HOUR).max(Decimal::zero())
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct UeberbauungTr {
pub w_a_kwh: Decimal,
pub p_inst_kw: Decimal,
}
pub fn ueberbauung_kuerzung(
trs: &[UeberbauungTr],
p_anschl_kw: Decimal,
einspeisung_netzlokation_kwh: Decimal,
) -> Result<Vec<Decimal>, AusfallarbeitError> {
if p_anschl_kw < Decimal::zero() {
return Err(AusfallarbeitError::NegativerWert("P_anschl"));
}
let cap = (p_anschl_kw * QUARTER_HOUR - einspeisung_netzlokation_kwh).max(Decimal::zero());
let summe: Decimal = trs.iter().map(|t| t.w_a_kwh).sum();
if summe <= cap {
return Ok(trs.iter().map(|t| t.w_a_kwh).collect());
}
let mut werte: Vec<Decimal> = trs.iter().map(|t| t.w_a_kwh).collect();
let mut aktiv: Vec<bool> = trs.iter().map(|t| t.w_a_kwh > Decimal::zero()).collect();
loop {
let ueberschuss: Decimal = werte
.iter()
.zip(&aktiv)
.map(|(w, a)| if *a { *w } else { Decimal::zero() })
.sum::<Decimal>()
- cap;
if ueberschuss <= Decimal::zero() {
break;
}
let p_inst_summe: Decimal = trs
.iter()
.zip(&aktiv)
.filter(|(_, a)| **a)
.map(|(t, _)| t.p_inst_kw)
.sum();
if p_inst_summe <= Decimal::zero() {
return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
}
let mut geclampt = false;
for (idx, tr) in trs.iter().enumerate() {
if !aktiv[idx] {
continue;
}
let anteil = ueberschuss * tr.p_inst_kw / p_inst_summe;
let neu = werte[idx] - anteil;
if neu < Decimal::zero() {
werte[idx] = Decimal::zero();
aktiv[idx] = false;
geclampt = true;
} else {
werte[idx] = neu;
}
}
if !geclampt {
break;
}
}
for (idx, w) in werte.iter_mut().enumerate() {
if !aktiv[idx] && trs[idx].w_a_kwh <= Decimal::zero() {
*w = trs[idx].w_a_kwh; }
}
Ok(werte)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::prelude::FromPrimitive;
use time::macros::{date, datetime, time};
fn dec(v: f64) -> Decimal {
Decimal::from_f64(v).expect("finite")
}
fn vs(
minute_offset: i64,
p_ist: f64,
gemessen: bool,
unbeschraenkt: bool,
) -> VergleichsViertelstunde {
VergleichsViertelstunde {
beginn: datetime!(2026-06-15 00:00 UTC) + time::Duration::minutes(minute_offset),
p_ist_kw: dec(p_ist),
p_theo_kw: dec(1000.0),
vollstaendig_gemessen: gemessen,
unbeschraenkt,
}
}
#[test]
fn vergleichszeitraum_takes_the_nearest_admissible_run() {
let mut kandidaten: Vec<VergleichsViertelstunde> =
(0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
kandidaten.extend((16..20).map(|i| vs(i * 15, 800.0, true, true)));
let z = vergleichszeitraum(
&kandidaten,
datetime!(2026-06-15 03:00 UTC),
datetime!(2026-06-15 03:00 UTC),
dec(1000.0),
)
.expect("an admissible run exists");
assert_eq!(z.lage, VergleichszeitraumLage::Danach);
assert_eq!(z.p_vz_ist_kw, dec(800.0));
assert_eq!(z.korrekturfaktor().unwrap(), dec(0.8));
}
#[test]
fn an_equal_distance_resolves_to_the_run_before() {
let mut kandidaten: Vec<VergleichsViertelstunde> =
(0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
kandidaten.extend((12..16).map(|i| vs(i * 15, 800.0, true, true)));
let z = vergleichszeitraum(
&kandidaten,
datetime!(2026-06-15 02:00 UTC),
datetime!(2026-06-15 02:00 UTC),
dec(1000.0),
)
.expect("an admissible run exists");
assert_eq!(z.lage, VergleichszeitraumLage::Davor);
assert_eq!(z.p_vz_ist_kw, dec(900.0));
}
#[test]
fn one_inadmissible_quarter_hour_disqualifies_the_whole_run() {
let massnahme = datetime!(2026-06-15 03:00 UTC);
for spoil in [
vs(30, 900.0, false, true), vs(30, 900.0, true, false), vs(30, 99.0, true, true), ] {
let mut kandidaten: Vec<VergleichsViertelstunde> =
(0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
kandidaten[2] = spoil;
assert_eq!(
vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)),
Err(AusfallarbeitError::KeinVergleichszeitraum)
);
}
let kandidaten: Vec<VergleichsViertelstunde> =
(0..4).map(|i| vs(i * 15, 100.0, true, true)).collect();
assert!(vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)).is_ok());
}
#[test]
fn the_folgemonat_is_never_reached_into() {
let kandidaten: Vec<VergleichsViertelstunde> = (0..4)
.map(|i| VergleichsViertelstunde {
beginn: datetime!(2026-07-01 00:00 UTC) + time::Duration::minutes(i * 15),
p_ist_kw: dec(900.0),
p_theo_kw: dec(1000.0),
vollstaendig_gemessen: true,
unbeschraenkt: true,
})
.collect();
assert_eq!(
vergleichszeitraum(
&kandidaten,
datetime!(2026-06-30 23:00 UTC),
datetime!(2026-06-30 23:45 UTC),
dec(1000.0)
),
Err(AusfallarbeitError::KeinVergleichszeitraum)
);
}
#[test]
fn a_gap_breaks_contiguity() {
let kandidaten = vec![
vs(0, 900.0, true, true),
vs(15, 900.0, true, true),
vs(45, 900.0, true, true), vs(60, 900.0, true, true),
];
assert_eq!(
vergleichszeitraum(
&kandidaten,
datetime!(2026-06-15 03:00 UTC),
datetime!(2026-06-15 03:00 UTC),
dec(1000.0)
),
Err(AusfallarbeitError::KeinVergleichszeitraum)
);
}
#[test]
fn the_danach_side_is_measured_from_the_end_of_the_massnahme() {
let mut kandidaten: Vec<VergleichsViertelstunde> =
(0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
kandidaten.extend((24..28).map(|i| vs(i * 15, 800.0, true, true)));
let z = vergleichszeitraum(
&kandidaten,
datetime!(2026-06-15 02:00 UTC),
datetime!(2026-06-15 06:00 UTC),
dec(1000.0),
)
.expect("an admissible run exists");
assert_eq!(z.lage, VergleichszeitraumLage::Danach);
assert_eq!(z.p_vz_ist_kw, dec(800.0));
}
fn tag_vs(
tag: Date,
stunde: u8,
p_ist: f64,
einstrahlung: f64,
gestoert: bool,
) -> VergleichstagViertelstunde {
VergleichstagViertelstunde {
beginn: tag.with_hms(stunde, 0, 0).expect("valid time").assume_utc(),
p_ist_kw: dec(p_ist),
einstrahlung_kw_m2: dec(einstrahlung),
nichtbeanspruchbar_oder_mba: gestoert,
}
}
#[test]
fn the_solar_vergleichstag_is_the_nearest_day_without_a_massnahme() {
let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
let kandidaten = vec![
tag_vs(d(12), 10, 900.0, 0.9, false),
tag_vs(d(13), 10, 800.0, 0.8, false), tag_vs(d(16), 10, 700.0, 0.7, false),
];
let z = solar_vergleichstag(&kandidaten, d(14), &[d(13)], dec(1000.0))
.expect("an admissible day exists");
assert_eq!(z.tag, d(12));
assert_eq!(z.lage, VergleichszeitraumLage::Davor);
assert_eq!(z.p_vz_ist_kw, dec(900.0));
assert_eq!(z.g_vz_kw_m2, dec(0.9));
}
#[test]
fn a_dark_or_curtailed_quarter_hour_is_left_out_of_the_means() {
let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
let kandidaten = vec![
tag_vs(d(12), 6, 50.0, 0.1, false), tag_vs(d(12), 10, 900.0, 0.9, false), tag_vs(d(12), 11, 700.0, 0.7, true), tag_vs(d(12), 12, 700.0, 0.7, false), ];
let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
.expect("an admissible day exists");
assert_eq!(z.viertelstunden, 2);
assert_eq!(z.p_vz_ist_kw, dec(800.0));
assert_eq!(z.g_vz_kw_m2, dec(0.8));
}
#[test]
fn a_dark_day_is_stepped_over() {
let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
let kandidaten = vec![
tag_vs(d(11), 10, 900.0, 0.9, false),
tag_vs(d(13), 10, 20.0, 0.02, false), ];
let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
.expect("the dark day is skipped, not fatal");
assert_eq!(z.tag, d(11));
}
#[test]
fn the_solar_vergleichstag_stays_in_the_month() {
let im_juni = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
let erster_juli = Date::from_calendar_date(2026, Month::July, 1).expect("valid date");
let kandidaten = vec![tag_vs(erster_juli, 10, 900.0, 0.9, false)];
assert_eq!(
solar_vergleichstag(&kandidaten, im_juni(30), &[], dec(1000.0)),
Err(AusfallarbeitError::KeinVergleichstag)
);
}
#[test]
fn leistungslimitierung_aufforderungsfall() {
let l = Leistungslimitierung::Aufforderung {
p_ist: dec(800.0),
vorgabe: dec(1000.0),
};
assert_eq!(l.wert(RedispatchRichtung::Positiv), dec(800.0));
let l = Leistungslimitierung::Aufforderung {
p_ist: dec(300.0),
vorgabe: dec(500.0),
};
assert_eq!(l.wert(RedispatchRichtung::Negativ), dec(500.0));
}
#[test]
fn leistungslimitierung_duldung_und_referenzprofil() {
let d = Leistungslimitierung::Duldung { p_ist: dec(420.0) };
assert_eq!(d.wert(RedispatchRichtung::Positiv), dec(420.0));
assert_eq!(d.wert(RedispatchRichtung::Negativ), dec(420.0));
let r = Leistungslimitierung::Referenzprofil {
vorgabe: dec(500.0),
};
assert_eq!(r.wert(RedispatchRichtung::Negativ), dec(500.0));
}
#[test]
fn korrekturfaktor_ratio_and_divisor_guard() {
assert_eq!(korrekturfaktor(dec(900.0), dec(1000.0)), Ok(dec(0.9)));
assert_eq!(
korrekturfaktor(dec(900.0), Decimal::ZERO),
Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"))
);
}
#[test]
fn wind_spitz_basic_and_nennleistung_cap() {
let mut input = WindSpitzInput {
kf: dec(0.9),
p_theo: dec(2000.0),
p_mba: None,
p_bean: None,
p_lim: dec(400.0),
p_nenn: dec(3000.0),
};
assert_eq!(wind_spitz(&input), dec(350.0));
input.kf = dec(1.8);
assert_eq!(wind_spitz(&input), dec(650.0));
input.kf = dec(0.9);
input.p_bean = Some(dec(1000.0));
assert_eq!(wind_spitz(&input), dec(150.0));
input.p_lim = dec(2500.0);
assert_eq!(wind_spitz(&input), Decimal::ZERO);
}
#[test]
fn wind_pauschal_fortschreibung() {
assert_eq!(
wind_pauschal(dec(1200.0), dec(2000.0), None, None, dec(200.0)),
dec(250.0)
);
assert_eq!(
wind_pauschal(dec(2500.0), dec(2000.0), None, None, dec(200.0)),
dec(450.0)
);
}
#[test]
fn wind_bin_index_centres_on_half_ms() {
assert_eq!(wind_bin_index(dec(0.0)), 0);
assert_eq!(wind_bin_index(dec(7.6)), 15); assert_eq!(wind_bin_index(dec(7.74)), 15);
assert_eq!(wind_bin_index(dec(7.8)), 16);
}
#[test]
fn kf_lbin_requires_three_wertepaare_and_clamps_at_zero() {
assert_eq!(
kf_lbin(&[dec(900.0), dec(950.0)], dec(1000.0)),
Err(AusfallarbeitError::BinUnterbesetzt(2))
);
let kf = kf_lbin(&[dec(900.0), dec(950.0), dec(1000.0)], dec(1000.0)).unwrap();
assert_eq!(kf, dec(0.95));
let kf = kf_lbin(&[dec(-10.0), dec(-20.0), dec(-30.0)], dec(1000.0)).unwrap();
assert_eq!(kf, Decimal::ZERO);
}
#[test]
fn kf_lbin_ersatzwert_chain_order() {
assert_eq!(
kf_lbin_ersatzwert(Some(dec(0.9)), Some(dec(0.8)), Some(dec(0.7))),
(dec(0.9), KfLbinQuelle::Vormonat)
);
assert_eq!(
kf_lbin_ersatzwert(None, Some(dec(0.8)), Some(dec(0.7))),
(dec(0.8), KfLbinQuelle::Folgemonat)
);
assert_eq!(
kf_lbin_ersatzwert(None, None, Some(dec(0.7))),
(dec(0.7), KfLbinQuelle::ZwoelfMonatsMittel)
);
assert_eq!(
kf_lbin_ersatzwert(None, None, None),
(Decimal::ONE, KfLbinQuelle::Standard)
);
}
#[test]
fn verlustfaktor_domain() {
assert_eq!(verlustfaktor(dec(970.0), dec(1000.0)), Ok(dec(0.97)));
assert!(matches!(
verlustfaktor(dec(1000.0), dec(1000.0)),
Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
));
assert!(matches!(
verlustfaktor(dec(1100.0), dec(1000.0)),
Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
));
assert!(matches!(
verlustfaktor(dec(0.0), dec(1000.0)),
Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
));
}
#[test]
fn wind_bin_composes_into_spitz_formula() {
let kf = kf_bin(dec(0.95), dec(0.97));
let input = WindSpitzInput {
kf,
p_theo: dec(1000.0),
p_mba: None,
p_bean: None,
p_lim: dec(121.5),
p_nenn: dec(5000.0),
};
assert_eq!(wind_spitz(&input), dec(200.0));
}
#[test]
fn solar_spitz_scales_by_irradiation() {
let input = SolarSpitzInput {
p_vz_ist: dec(800.0),
g_vz: dec(0.4),
g_i: dec(0.6),
p_wr: dec(1500.0),
p_mba: None,
p_bean: None,
p_lim: dec(200.0),
p_nenn: dec(1400.0),
};
assert_eq!(solar_spitz(&input).unwrap(), dec(250.0));
let engpass = SolarSpitzInput {
p_wr: dec(1000.0),
..input
};
assert_eq!(solar_spitz(&engpass).unwrap(), dec(200.0));
let kaputt = SolarSpitzInput {
g_vz: Decimal::ZERO,
..input
};
assert_eq!(
solar_spitz(&kaputt),
Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"))
);
}
#[test]
fn anlagenfaktor_table_summer_winter() {
assert_eq!(
anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)),
dec(0.6189)
);
assert_eq!(
anlagenfaktor(date!(2027 - 06 - 15), time!(06:00)),
dec(0.2456)
);
assert_eq!(
anlagenfaktor(date!(2027 - 06 - 15), time!(09:00)),
dec(0.6189)
);
assert_eq!(
anlagenfaktor(date!(2027 - 06 - 15), time!(19:00)),
Decimal::ZERO
);
assert_eq!(
anlagenfaktor(date!(2027 - 01 - 15), time!(12:00)),
dec(0.5030)
);
assert_eq!(
anlagenfaktor(date!(2027 - 01 - 15), time!(09:15)),
dec(0.2796)
);
assert_eq!(
anlagenfaktor(date!(2027 - 01 - 15), time!(16:30)),
dec(0.2796)
);
assert_eq!(
anlagenfaktor(date!(2027 - 01 - 15), time!(16:45)),
Decimal::ZERO
);
assert_eq!(
anlagenfaktor(date!(2027 - 03 - 01), time!(12:00)),
dec(0.6189)
);
assert_eq!(
anlagenfaktor(date!(2027 - 11 - 01), time!(12:00)),
dec(0.5030)
);
}
#[test]
fn solar_pauschal_uses_af_and_wr() {
let af = anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)); assert_eq!(
solar_pauschal(af, dec(1000.0), dec(600.0), None, None, dec(100.0)),
dec(125.0)
);
}
#[test]
fn nichtfluktuierend_spitz_sign_convention() {
assert_eq!(
nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(1000.0), dec(400.0)),
dec(150.0)
);
assert_eq!(
nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(400.0), dec(1000.0)),
dec(-150.0)
);
assert_eq!(
nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(1000.0), dec(400.0)),
Decimal::ZERO
);
assert_eq!(
nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(400.0), dec(1000.0)),
Decimal::ZERO
);
}
#[test]
fn nichtfluktuierend_pauschal_final_formulas() {
assert_eq!(
nichtfluktuierend_pauschal(
RedispatchRichtung::Negativ,
dec(1200.0),
Some(dec(1100.0)),
dec(300.0)
),
dec(200.0)
);
assert_eq!(
nichtfluktuierend_pauschal(
RedispatchRichtung::Positiv,
dec(400.0),
Some(dec(800.0)),
dec(1000.0)
),
dec(-100.0)
);
assert_eq!(
nichtfluktuierend_pauschal(RedispatchRichtung::Negativ, dec(1200.0), None, dec(300.0)),
dec(225.0)
);
}
#[test]
fn ueberbauung_no_cut_when_under_cap() {
let trs = [
UeberbauungTr {
w_a_kwh: dec(100.0),
p_inst_kw: dec(2000.0),
},
UeberbauungTr {
w_a_kwh: dec(50.0),
p_inst_kw: dec(1000.0),
},
];
let out = ueberbauung_kuerzung(&trs, dec(1000.0), dec(50.0)).unwrap();
assert_eq!(out, vec![dec(100.0), dec(50.0)]);
}
#[test]
fn ueberbauung_pro_rata_by_installed_capacity() {
let trs = [
UeberbauungTr {
w_a_kwh: dec(100.0),
p_inst_kw: dec(2000.0),
},
UeberbauungTr {
w_a_kwh: dec(50.0),
p_inst_kw: dec(1000.0),
},
];
let out = ueberbauung_kuerzung(&trs, dec(400.0), dec(10.0)).unwrap();
assert_eq!(out, vec![dec(60.0), dec(30.0)]);
assert_eq!(out.iter().copied().sum::<Decimal>(), dec(90.0));
}
#[test]
fn ueberbauung_clamps_at_zero_and_redistributes() {
let trs = [
UeberbauungTr {
w_a_kwh: dec(10.0),
p_inst_kw: dec(3000.0),
},
UeberbauungTr {
w_a_kwh: dec(200.0),
p_inst_kw: dec(1000.0),
},
];
let out = ueberbauung_kuerzung(&trs, dec(400.0), Decimal::ZERO).unwrap();
assert_eq!(out[0], Decimal::ZERO);
assert_eq!(out[1], dec(100.0));
assert_eq!(out.iter().copied().sum::<Decimal>(), dec(100.0));
}
#[test]
fn malo_split_pro_rata() {
let out = malo_wert_auf_tr(dec(900.0), &[dec(2000.0), dec(1000.0)]).unwrap();
assert_eq!(out, vec![dec(600.0), dec(300.0)]);
assert!(malo_wert_auf_tr(dec(900.0), &[]).is_err());
}
}