1use rust_decimal::Decimal;
23use rust_decimal::prelude::Zero;
24use time::{Date, Month, OffsetDateTime, Time};
25
26pub const WETTERDATEN_LIEFERFRIST_WERKTAGE: u8 = 4;
32
33pub const PAUSCHAL_WECHSEL_FRIST_MONATE: u8 = 3;
37
38pub const VERGLEICHSZEITRAUM_MINDESTANTEIL: Decimal = Decimal::from_parts(1, 0, 0, false, 1); pub const VERGLEICHSZEITRAUM_VIERTELSTUNDEN: usize = 4;
47
48pub const WIND_BIN_MINDEST_WERTEPAARE: usize = 3;
51
52pub 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)]
61pub enum AusfallarbeitError {
62 #[error("unzulässiger Divisor: {0}")]
64 UnzulaessigerDivisor(&'static str),
65 #[error("Verlustfaktor {0} liegt nicht in ]0;1[")]
67 VerlustfaktorAusserhalb(Decimal),
68 #[error(
71 "kein zulässiger Vergleichszeitraum: keine {VERGLEICHSZEITRAUM_VIERTELSTUNDEN} \
72 zusammenhängenden, vollständig gemessenen Viertelstunden mit unbeschränkter \
73 Einspeisung ≥ 10 % der Nennleistung im Monat der Maßnahme"
74 )]
75 KeinVergleichszeitraum,
76 #[error(
79 "kein zulässiger Vergleichstag: kein Kalendertag im Monat der Maßnahme ohne \
80 Redispatch-Maßnahme mit mindestens einer Viertelstunde ≥ 10 % der Nennleistung \
81 ohne Nichtbeanspruchbarkeit oder marktbedingte Anpassung"
82 )]
83 KeinVergleichstag,
84 #[error("Wind-Bin unterbesetzt: {0} Wertepaare (< {WIND_BIN_MINDEST_WERTEPAARE})")]
86 BinUnterbesetzt(usize),
87 #[error("negativer Eingabewert: {0}")]
89 NegativerWert(&'static str),
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum RedispatchRichtung {
98 Positiv,
100 Negativ,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107#[serde(rename_all = "snake_case", tag = "fall")]
108pub enum Leistungslimitierung {
109 Aufforderung {
112 p_ist: Decimal,
114 vorgabe: Decimal,
116 },
117 Duldung {
119 p_ist: Decimal,
121 },
122 Referenzprofil {
125 vorgabe: Decimal,
127 },
128}
129
130impl Leistungslimitierung {
131 #[must_use]
137 pub fn wert(self, richtung: RedispatchRichtung) -> Decimal {
138 match self {
139 Self::Aufforderung { p_ist, vorgabe } => match richtung {
140 RedispatchRichtung::Positiv => p_ist.min(vorgabe),
141 RedispatchRichtung::Negativ => p_ist.max(vorgabe),
142 },
143 Self::Duldung { p_ist } => p_ist,
144 Self::Referenzprofil { vorgabe } => vorgabe,
145 }
146 }
147}
148
149pub fn malo_wert_auf_tr(
158 malo_wert: Decimal,
159 p_inst_kw: &[Decimal],
160) -> Result<Vec<Decimal>, AusfallarbeitError> {
161 let summe: Decimal = p_inst_kw.iter().copied().sum();
162 if summe <= Decimal::zero() {
163 return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
164 }
165 Ok(p_inst_kw.iter().map(|p| malo_wert * p / summe).collect())
166}
167
168fn w_a(theo: Decimal, bounds: &[Option<Decimal>], p_lim: Decimal, negativ: bool) -> Decimal {
171 let mut m = theo;
172 for b in bounds.iter().copied().flatten() {
173 m = m.min(b);
174 }
175 let w = (m - p_lim) * QUARTER_HOUR;
176 if negativ {
177 w.max(Decimal::zero())
178 } else {
179 w.min(Decimal::zero())
180 }
181}
182
183pub fn korrekturfaktor(
196 p_vz_ist: Decimal,
197 p_vz_theo: Decimal,
198) -> Result<Decimal, AusfallarbeitError> {
199 if p_vz_theo <= Decimal::zero() {
200 return Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"));
201 }
202 Ok(p_vz_ist / p_vz_theo)
203}
204
205#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
210pub struct WindSpitzInput {
211 pub kf: Decimal,
213 pub p_theo: Decimal,
216 pub p_mba: Option<Decimal>,
218 pub p_bean: Option<Decimal>,
221 pub p_lim: Decimal,
223 pub p_nenn: Decimal,
225}
226
227#[must_use]
233pub fn wind_spitz(input: &WindSpitzInput) -> Decimal {
234 let theo = (input.kf * input.p_theo).min(input.p_nenn);
235 w_a(theo, &[input.p_mba, input.p_bean], input.p_lim, true)
236}
237
238#[must_use]
243pub fn wind_pauschal(
244 p_0: Decimal,
245 p_inst: Decimal,
246 p_mba: Option<Decimal>,
247 p_bean: Option<Decimal>,
248 p_lim: Decimal,
249) -> Decimal {
250 w_a(p_0.min(p_inst), &[p_mba, p_bean], p_lim, true)
251}
252
253mod rfc3339_vec {
260 use serde::{Deserialize, Deserializer, Serialize, Serializer};
261 use time::OffsetDateTime;
262
263 #[derive(Serialize, Deserialize)]
264 struct One(#[serde(with = "time::serde::rfc3339")] OffsetDateTime);
265
266 pub(super) fn serialize<S: Serializer>(
267 value: &[OffsetDateTime],
268 serializer: S,
269 ) -> Result<S::Ok, S::Error> {
270 serializer.collect_seq(value.iter().copied().map(One))
271 }
272
273 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
274 deserializer: D,
275 ) -> Result<Vec<OffsetDateTime>, D::Error> {
276 Ok(Vec::<One>::deserialize(deserializer)?
277 .into_iter()
278 .map(|One(v)| v)
279 .collect())
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
288pub struct VergleichsViertelstunde {
289 #[serde(with = "time::serde::rfc3339")]
295 pub beginn: OffsetDateTime,
296 pub p_ist_kw: Decimal,
298 pub p_theo_kw: Decimal,
301 pub vollstaendig_gemessen: bool,
304 pub unbeschraenkt: bool,
307}
308
309impl VergleichsViertelstunde {
310 fn zulaessig(&self, p_nenn_kw: Decimal) -> bool {
313 self.vollstaendig_gemessen
314 && self.unbeschraenkt
315 && self.p_ist_kw >= p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL
316 }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum VergleichszeitraumLage {
323 Davor,
325 Danach,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
331pub struct Vergleichszeitraum {
332 pub p_vz_ist_kw: Decimal,
334 pub p_vz_theo_kw: Decimal,
336 pub lage: VergleichszeitraumLage,
338 #[serde(with = "rfc3339_vec")]
340 pub viertelstunden: Vec<OffsetDateTime>,
341}
342
343impl Vergleichszeitraum {
344 pub fn korrekturfaktor(&self) -> Result<Decimal, AusfallarbeitError> {
350 korrekturfaktor(self.p_vz_ist_kw, self.p_vz_theo_kw)
351 }
352}
353
354pub fn vergleichszeitraum(
385 kandidaten: &[VergleichsViertelstunde],
386 massnahme_beginn: OffsetDateTime,
387 massnahme_ende: OffsetDateTime,
388 p_nenn_kw: Decimal,
389) -> Result<Vergleichszeitraum, AusfallarbeitError> {
390 let n = VERGLEICHSZEITRAUM_VIERTELSTUNDEN;
391 let viertelstunde = time::Duration::minutes(15);
392 let monat = (massnahme_beginn.year(), massnahme_beginn.month());
393
394 let mut best: Option<(
395 time::Duration,
396 VergleichszeitraumLage,
397 &[VergleichsViertelstunde],
398 )> = None;
399
400 for run in kandidaten.windows(n) {
401 if run
403 .windows(2)
404 .any(|pair| pair[1].beginn - pair[0].beginn != viertelstunde)
405 {
406 continue;
407 }
408 if !run.iter().all(|vs| vs.zulaessig(p_nenn_kw)) {
409 continue;
410 }
411
412 let ende = run[n - 1].beginn + viertelstunde;
413 let (abstand, lage) = if ende <= massnahme_beginn {
414 (massnahme_beginn - ende, VergleichszeitraumLage::Davor)
415 } else if run[0].beginn >= massnahme_ende {
416 (
417 run[0].beginn - massnahme_ende,
418 VergleichszeitraumLage::Danach,
419 )
420 } else {
421 continue;
424 };
425
426 if run
431 .iter()
432 .any(|vs| (vs.beginn.year(), vs.beginn.month()) != monat)
433 {
434 continue;
435 }
436
437 let better = match &best {
441 None => true,
442 Some((d, l, _)) => {
443 abstand < *d
444 || (abstand == *d
445 && *l == VergleichszeitraumLage::Danach
446 && lage == VergleichszeitraumLage::Davor)
447 }
448 };
449 if better {
450 best = Some((abstand, lage, run));
451 }
452 }
453
454 let Some((_, lage, run)) = best else {
455 return Err(AusfallarbeitError::KeinVergleichszeitraum);
456 };
457 let teiler = Decimal::from(u64::try_from(n).unwrap_or(u64::MAX));
458 Ok(Vergleichszeitraum {
459 p_vz_ist_kw: run.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
460 p_vz_theo_kw: run.iter().map(|vs| vs.p_theo_kw).sum::<Decimal>() / teiler,
461 lage,
462 viertelstunden: run.iter().map(|vs| vs.beginn).collect(),
463 })
464}
465
466#[must_use]
471pub fn wind_bin_index(windgeschwindigkeit_ms: Decimal) -> i64 {
472 let idx = (windgeschwindigkeit_ms / WIND_BIN_BREITE_MS)
476 .round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
477 idx.try_into().unwrap_or(i64::MAX)
478}
479
480pub fn kf_lbin(
490 leistungswerte_kw: &[Decimal],
491 p_zert_lk: Decimal,
492) -> Result<Decimal, AusfallarbeitError> {
493 let m = leistungswerte_kw.len();
494 if m < WIND_BIN_MINDEST_WERTEPAARE {
495 return Err(AusfallarbeitError::BinUnterbesetzt(m));
496 }
497 if p_zert_lk <= Decimal::zero() {
498 return Err(AusfallarbeitError::UnzulaessigerDivisor("P_zertLK"));
499 }
500 let mittel: Decimal = leistungswerte_kw.iter().copied().sum::<Decimal>()
501 / Decimal::from(u64::try_from(m).unwrap_or(u64::MAX));
502 Ok((mittel / p_zert_lk).max(Decimal::zero()))
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum KfLbinQuelle {
509 Monat,
511 Vormonat,
513 Folgemonat,
515 ZwoelfMonatsMittel,
517 Standard,
520}
521
522#[must_use]
525pub fn kf_lbin_ersatzwert(
526 vormonat: Option<Decimal>,
527 folgemonat: Option<Decimal>,
528 zwoelf_monats_mittel: Option<Decimal>,
529) -> (Decimal, KfLbinQuelle) {
530 if let Some(v) = vormonat {
531 (v, KfLbinQuelle::Vormonat)
532 } else if let Some(v) = folgemonat {
533 (v, KfLbinQuelle::Folgemonat)
534 } else if let Some(v) = zwoelf_monats_mittel {
535 (v, KfLbinQuelle::ZwoelfMonatsMittel)
536 } else {
537 (Decimal::ONE, KfLbinQuelle::Standard)
538 }
539}
540
541pub fn verlustfaktor(
550 e_einsp_kwh: Decimal,
551 summe_e_wea_kwh: Decimal,
552) -> Result<Decimal, AusfallarbeitError> {
553 if summe_e_wea_kwh <= Decimal::zero() {
554 return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ E_WEA"));
555 }
556 let kf_v = e_einsp_kwh / summe_e_wea_kwh;
557 if kf_v <= Decimal::zero() || kf_v >= Decimal::ONE {
558 return Err(AusfallarbeitError::VerlustfaktorAusserhalb(kf_v));
559 }
560 Ok(kf_v)
561}
562
563#[must_use]
565pub fn kf_bin(kf_lbin: Decimal, kf_v: Decimal) -> Decimal {
566 kf_lbin * kf_v
567}
568
569#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
575pub struct SolarSpitzInput {
576 pub p_vz_ist: Decimal,
578 pub g_vz: Decimal,
580 pub g_i: Decimal,
582 pub p_wr: Decimal,
585 pub p_mba: Option<Decimal>,
587 pub p_bean: Option<Decimal>,
589 pub p_lim: Decimal,
591 pub p_nenn: Decimal,
594}
595
596pub fn solar_spitz(input: &SolarSpitzInput) -> Result<Decimal, AusfallarbeitError> {
604 if input.g_vz <= Decimal::zero() {
605 return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
606 }
607 let theo = (input.p_vz_ist / input.g_vz * input.g_i).min(input.p_nenn);
608 Ok(w_a(
609 theo,
610 &[Some(input.p_wr), input.p_mba, input.p_bean],
611 input.p_lim,
612 true,
613 ))
614}
615
616#[must_use]
621pub fn anlagenfaktor(datum: Date, zeit: Time) -> Decimal {
622 let sommer = matches!(
623 datum.month(),
624 Month::March
625 | Month::April
626 | Month::May
627 | Month::June
628 | Month::July
629 | Month::August
630 | Month::September
631 | Month::October
632 );
633 let minuten = i32::from(zeit.hour()) * 60 + i32::from(zeit.minute());
634 let af = |zehntausendstel: i64| Decimal::new(zehntausendstel, 4);
635 if sommer {
636 match minuten {
637 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(), }
642 } else {
643 match minuten {
644 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(), }
649 }
650}
651
652#[must_use]
656pub fn solar_pauschal(
657 af: Decimal,
658 p_inst_module: Decimal,
659 p_wr: Decimal,
660 p_mba: Option<Decimal>,
661 p_bean: Option<Decimal>,
662 p_lim: Decimal,
663) -> Decimal {
664 w_a(
665 af * p_inst_module,
666 &[Some(p_wr), p_mba, p_bean],
667 p_lim,
668 true,
669 )
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
680pub struct VergleichstagViertelstunde {
681 #[serde(with = "time::serde::rfc3339")]
683 pub beginn: OffsetDateTime,
684 pub p_ist_kw: Decimal,
686 pub einstrahlung_kw_m2: Decimal,
688 pub nichtbeanspruchbar_oder_mba: bool,
691}
692
693#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
695pub struct Vergleichstag {
696 pub tag: Date,
698 pub p_vz_ist_kw: Decimal,
701 pub g_vz_kw_m2: Decimal,
703 pub lage: VergleichszeitraumLage,
705 pub viertelstunden: usize,
707}
708
709pub fn solar_vergleichstag(
738 kandidaten: &[VergleichstagViertelstunde],
739 massnahme_tag: Date,
740 tage_mit_massnahme: &[Date],
741 p_nenn_kw: Decimal,
742) -> Result<Vergleichstag, AusfallarbeitError> {
743 let schwelle = p_nenn_kw * VERGLEICHSZEITRAUM_MINDESTANTEIL;
744 let mut best: Option<(i64, VergleichszeitraumLage, Date)> = None;
745
746 let mut tage: Vec<Date> = kandidaten.iter().map(|vs| vs.beginn.date()).collect();
749 tage.sort_unstable();
750 tage.dedup();
751
752 for tag in tage {
753 if tag == massnahme_tag || tage_mit_massnahme.contains(&tag) {
754 continue;
755 }
756 if (tag.year(), tag.month()) != (massnahme_tag.year(), massnahme_tag.month()) {
760 continue;
761 }
762 if !kandidaten
763 .iter()
764 .any(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
765 {
766 continue;
767 }
768 let abstand = (tag - massnahme_tag).whole_days();
769 let lage = if abstand < 0 {
770 VergleichszeitraumLage::Davor
771 } else {
772 VergleichszeitraumLage::Danach
773 };
774 let entfernung = abstand.abs();
775 let better = match &best {
776 None => true,
777 Some((d, l, _)) => {
778 entfernung < *d
779 || (entfernung == *d
780 && *l == VergleichszeitraumLage::Danach
781 && lage == VergleichszeitraumLage::Davor)
782 }
783 };
784 if better {
785 best = Some((entfernung, lage, tag));
786 }
787 }
788
789 let Some((_, lage, tag)) = best else {
790 return Err(AusfallarbeitError::KeinVergleichstag);
791 };
792
793 let admitted: Vec<&VergleichstagViertelstunde> = kandidaten
794 .iter()
795 .filter(|vs| vs.beginn.date() == tag && zulaessig(vs, schwelle))
796 .collect();
797 let teiler = Decimal::from(u64::try_from(admitted.len()).unwrap_or(u64::MAX));
798 let g_vz = admitted
799 .iter()
800 .map(|vs| vs.einstrahlung_kw_m2)
801 .sum::<Decimal>()
802 / teiler;
803 if g_vz <= Decimal::zero() {
804 return Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"));
805 }
806 Ok(Vergleichstag {
807 tag,
808 p_vz_ist_kw: admitted.iter().map(|vs| vs.p_ist_kw).sum::<Decimal>() / teiler,
809 g_vz_kw_m2: g_vz,
810 lage,
811 viertelstunden: admitted.len(),
812 })
813}
814
815fn zulaessig(vs: &VergleichstagViertelstunde, schwelle_kw: Decimal) -> bool {
818 !vs.nichtbeanspruchbar_oder_mba && vs.p_ist_kw >= schwelle_kw
819}
820
821#[must_use]
828pub fn nichtfluktuierend_spitz(
829 richtung: RedispatchRichtung,
830 p_plan: Decimal,
831 p_lim: Decimal,
832) -> Decimal {
833 let w = (p_plan - p_lim) * QUARTER_HOUR;
834 match richtung {
835 RedispatchRichtung::Positiv => w.min(Decimal::zero()),
836 RedispatchRichtung::Negativ => w.max(Decimal::zero()),
837 }
838}
839
840#[must_use]
846pub fn nichtfluktuierend_pauschal(
847 richtung: RedispatchRichtung,
848 p_0: Decimal,
849 p_bean: Option<Decimal>,
850 p_lim: Decimal,
851) -> Decimal {
852 match richtung {
853 RedispatchRichtung::Positiv => {
854 let grenze = p_bean.map_or(p_lim, |b| p_lim.min(b));
855 ((p_0 - grenze) * QUARTER_HOUR).min(Decimal::zero())
856 }
857 RedispatchRichtung::Negativ => {
858 let basis = p_bean.map_or(p_0, |b| p_0.min(b));
859 ((basis - p_lim) * QUARTER_HOUR).max(Decimal::zero())
860 }
861 }
862}
863
864#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
868pub struct UeberbauungTr {
869 pub w_a_kwh: Decimal,
871 pub p_inst_kw: Decimal,
873}
874
875pub fn ueberbauung_kuerzung(
889 trs: &[UeberbauungTr],
890 p_anschl_kw: Decimal,
891 einspeisung_netzlokation_kwh: Decimal,
892) -> Result<Vec<Decimal>, AusfallarbeitError> {
893 if p_anschl_kw < Decimal::zero() {
894 return Err(AusfallarbeitError::NegativerWert("P_anschl"));
895 }
896 let cap = (p_anschl_kw * QUARTER_HOUR - einspeisung_netzlokation_kwh).max(Decimal::zero());
897 let summe: Decimal = trs.iter().map(|t| t.w_a_kwh).sum();
898 if summe <= cap {
899 return Ok(trs.iter().map(|t| t.w_a_kwh).collect());
900 }
901 let mut werte: Vec<Decimal> = trs.iter().map(|t| t.w_a_kwh).collect();
905 let mut aktiv: Vec<bool> = trs.iter().map(|t| t.w_a_kwh > Decimal::zero()).collect();
906 loop {
907 let ueberschuss: Decimal = werte
908 .iter()
909 .zip(&aktiv)
910 .map(|(w, a)| if *a { *w } else { Decimal::zero() })
911 .sum::<Decimal>()
912 - cap;
913 if ueberschuss <= Decimal::zero() {
914 break;
915 }
916 let p_inst_summe: Decimal = trs
917 .iter()
918 .zip(&aktiv)
919 .filter(|(_, a)| **a)
920 .map(|(t, _)| t.p_inst_kw)
921 .sum();
922 if p_inst_summe <= Decimal::zero() {
923 return Err(AusfallarbeitError::UnzulaessigerDivisor("Σ P_inst"));
924 }
925 let mut geclampt = false;
926 for (idx, tr) in trs.iter().enumerate() {
927 if !aktiv[idx] {
928 continue;
929 }
930 let anteil = ueberschuss * tr.p_inst_kw / p_inst_summe;
931 let neu = werte[idx] - anteil;
932 if neu < Decimal::zero() {
933 werte[idx] = Decimal::zero();
934 aktiv[idx] = false;
935 geclampt = true;
936 } else {
937 werte[idx] = neu;
938 }
939 }
940 if !geclampt {
941 break;
942 }
943 }
945 for (idx, w) in werte.iter_mut().enumerate() {
946 if !aktiv[idx] && trs[idx].w_a_kwh <= Decimal::zero() {
947 *w = trs[idx].w_a_kwh; }
949 }
950 Ok(werte)
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use rust_decimal::prelude::FromPrimitive;
957 use time::macros::{date, datetime, time};
958
959 fn dec(v: f64) -> Decimal {
960 Decimal::from_f64(v).expect("finite")
961 }
962
963 fn vs(
966 minute_offset: i64,
967 p_ist: f64,
968 gemessen: bool,
969 unbeschraenkt: bool,
970 ) -> VergleichsViertelstunde {
971 VergleichsViertelstunde {
972 beginn: datetime!(2026-06-15 00:00 UTC) + time::Duration::minutes(minute_offset),
973 p_ist_kw: dec(p_ist),
974 p_theo_kw: dec(1000.0),
975 vollstaendig_gemessen: gemessen,
976 unbeschraenkt,
977 }
978 }
979
980 #[test]
983 fn vergleichszeitraum_takes_the_nearest_admissible_run() {
984 let mut kandidaten: Vec<VergleichsViertelstunde> =
987 (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
988 kandidaten.extend((16..20).map(|i| vs(i * 15, 800.0, true, true)));
989
990 let z = vergleichszeitraum(
991 &kandidaten,
992 datetime!(2026-06-15 03:00 UTC),
993 datetime!(2026-06-15 03:00 UTC),
994 dec(1000.0),
995 )
996 .expect("an admissible run exists");
997 assert_eq!(z.lage, VergleichszeitraumLage::Danach);
998 assert_eq!(z.p_vz_ist_kw, dec(800.0));
999 assert_eq!(z.korrekturfaktor().unwrap(), dec(0.8));
1000 }
1001
1002 #[test]
1004 fn an_equal_distance_resolves_to_the_run_before() {
1005 let mut kandidaten: Vec<VergleichsViertelstunde> =
1008 (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1009 kandidaten.extend((12..16).map(|i| vs(i * 15, 800.0, true, true)));
1010
1011 let z = vergleichszeitraum(
1012 &kandidaten,
1013 datetime!(2026-06-15 02:00 UTC),
1014 datetime!(2026-06-15 02:00 UTC),
1015 dec(1000.0),
1016 )
1017 .expect("an admissible run exists");
1018 assert_eq!(z.lage, VergleichszeitraumLage::Davor);
1019 assert_eq!(z.p_vz_ist_kw, dec(900.0));
1020 }
1021
1022 #[test]
1025 fn one_inadmissible_quarter_hour_disqualifies_the_whole_run() {
1026 let massnahme = datetime!(2026-06-15 03:00 UTC);
1027 for spoil in [
1028 vs(30, 900.0, false, true), vs(30, 900.0, true, false), vs(30, 99.0, true, true), ] {
1032 let mut kandidaten: Vec<VergleichsViertelstunde> =
1033 (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1034 kandidaten[2] = spoil;
1035 assert_eq!(
1036 vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)),
1037 Err(AusfallarbeitError::KeinVergleichszeitraum)
1038 );
1039 }
1040
1041 let kandidaten: Vec<VergleichsViertelstunde> =
1043 (0..4).map(|i| vs(i * 15, 100.0, true, true)).collect();
1044 assert!(vergleichszeitraum(&kandidaten, massnahme, massnahme, dec(1000.0)).is_ok());
1045 }
1046
1047 #[test]
1049 fn the_folgemonat_is_never_reached_into() {
1050 let kandidaten: Vec<VergleichsViertelstunde> = (0..4)
1052 .map(|i| VergleichsViertelstunde {
1053 beginn: datetime!(2026-07-01 00:00 UTC) + time::Duration::minutes(i * 15),
1054 p_ist_kw: dec(900.0),
1055 p_theo_kw: dec(1000.0),
1056 vollstaendig_gemessen: true,
1057 unbeschraenkt: true,
1058 })
1059 .collect();
1060 assert_eq!(
1061 vergleichszeitraum(
1062 &kandidaten,
1063 datetime!(2026-06-30 23:00 UTC),
1064 datetime!(2026-06-30 23:45 UTC),
1065 dec(1000.0)
1066 ),
1067 Err(AusfallarbeitError::KeinVergleichszeitraum)
1068 );
1069 }
1070
1071 #[test]
1073 fn a_gap_breaks_contiguity() {
1074 let kandidaten = vec![
1075 vs(0, 900.0, true, true),
1076 vs(15, 900.0, true, true),
1077 vs(45, 900.0, true, true), vs(60, 900.0, true, true),
1079 ];
1080 assert_eq!(
1081 vergleichszeitraum(
1082 &kandidaten,
1083 datetime!(2026-06-15 03:00 UTC),
1084 datetime!(2026-06-15 03:00 UTC),
1085 dec(1000.0)
1086 ),
1087 Err(AusfallarbeitError::KeinVergleichszeitraum)
1088 );
1089 }
1090
1091 #[test]
1095 fn the_danach_side_is_measured_from_the_end_of_the_massnahme() {
1096 let mut kandidaten: Vec<VergleichsViertelstunde> =
1101 (0..4).map(|i| vs(i * 15, 900.0, true, true)).collect();
1102 kandidaten.extend((24..28).map(|i| vs(i * 15, 800.0, true, true)));
1103
1104 let z = vergleichszeitraum(
1105 &kandidaten,
1106 datetime!(2026-06-15 02:00 UTC),
1107 datetime!(2026-06-15 06:00 UTC),
1108 dec(1000.0),
1109 )
1110 .expect("an admissible run exists");
1111 assert_eq!(z.lage, VergleichszeitraumLage::Danach);
1112 assert_eq!(z.p_vz_ist_kw, dec(800.0));
1113 }
1114
1115 fn tag_vs(
1118 tag: Date,
1119 stunde: u8,
1120 p_ist: f64,
1121 einstrahlung: f64,
1122 gestoert: bool,
1123 ) -> VergleichstagViertelstunde {
1124 VergleichstagViertelstunde {
1125 beginn: tag.with_hms(stunde, 0, 0).expect("valid time").assume_utc(),
1126 p_ist_kw: dec(p_ist),
1127 einstrahlung_kw_m2: dec(einstrahlung),
1128 nichtbeanspruchbar_oder_mba: gestoert,
1129 }
1130 }
1131
1132 #[test]
1136 fn the_solar_vergleichstag_is_the_nearest_day_without_a_massnahme() {
1137 let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1138 let kandidaten = vec![
1139 tag_vs(d(12), 10, 900.0, 0.9, false),
1140 tag_vs(d(13), 10, 800.0, 0.8, false), tag_vs(d(16), 10, 700.0, 0.7, false),
1142 ];
1143 let z = solar_vergleichstag(&kandidaten, d(14), &[d(13)], dec(1000.0))
1146 .expect("an admissible day exists");
1147 assert_eq!(z.tag, d(12));
1148 assert_eq!(z.lage, VergleichszeitraumLage::Davor);
1149 assert_eq!(z.p_vz_ist_kw, dec(900.0));
1150 assert_eq!(z.g_vz_kw_m2, dec(0.9));
1151 }
1152
1153 #[test]
1156 fn a_dark_or_curtailed_quarter_hour_is_left_out_of_the_means() {
1157 let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1158 let kandidaten = vec![
1159 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), ];
1164 let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
1165 .expect("an admissible day exists");
1166 assert_eq!(z.viertelstunden, 2);
1167 assert_eq!(z.p_vz_ist_kw, dec(800.0));
1168 assert_eq!(z.g_vz_kw_m2, dec(0.8));
1169 }
1170
1171 #[test]
1175 fn a_dark_day_is_stepped_over() {
1176 let d = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1177 let kandidaten = vec![
1178 tag_vs(d(11), 10, 900.0, 0.9, false),
1179 tag_vs(d(13), 10, 20.0, 0.02, false), ];
1181 let z = solar_vergleichstag(&kandidaten, d(14), &[], dec(1000.0))
1182 .expect("the dark day is skipped, not fatal");
1183 assert_eq!(z.tag, d(11));
1184 }
1185
1186 #[test]
1188 fn the_solar_vergleichstag_stays_in_the_month() {
1189 let im_juni = |day| Date::from_calendar_date(2026, Month::June, day).expect("valid date");
1190 let erster_juli = Date::from_calendar_date(2026, Month::July, 1).expect("valid date");
1191 let kandidaten = vec![tag_vs(erster_juli, 10, 900.0, 0.9, false)];
1192 assert_eq!(
1193 solar_vergleichstag(&kandidaten, im_juni(30), &[], dec(1000.0)),
1194 Err(AusfallarbeitError::KeinVergleichstag)
1195 );
1196 }
1197
1198 #[test]
1201 fn leistungslimitierung_aufforderungsfall() {
1202 let l = Leistungslimitierung::Aufforderung {
1204 p_ist: dec(800.0),
1205 vorgabe: dec(1000.0),
1206 };
1207 assert_eq!(l.wert(RedispatchRichtung::Positiv), dec(800.0));
1208 let l = Leistungslimitierung::Aufforderung {
1210 p_ist: dec(300.0),
1211 vorgabe: dec(500.0),
1212 };
1213 assert_eq!(l.wert(RedispatchRichtung::Negativ), dec(500.0));
1214 }
1215
1216 #[test]
1217 fn leistungslimitierung_duldung_und_referenzprofil() {
1218 let d = Leistungslimitierung::Duldung { p_ist: dec(420.0) };
1219 assert_eq!(d.wert(RedispatchRichtung::Positiv), dec(420.0));
1220 assert_eq!(d.wert(RedispatchRichtung::Negativ), dec(420.0));
1221 let r = Leistungslimitierung::Referenzprofil {
1222 vorgabe: dec(500.0),
1223 };
1224 assert_eq!(r.wert(RedispatchRichtung::Negativ), dec(500.0));
1225 }
1226
1227 #[test]
1230 fn korrekturfaktor_ratio_and_divisor_guard() {
1231 assert_eq!(korrekturfaktor(dec(900.0), dec(1000.0)), Ok(dec(0.9)));
1232 assert_eq!(
1233 korrekturfaktor(dec(900.0), Decimal::ZERO),
1234 Err(AusfallarbeitError::UnzulaessigerDivisor("P_VZ,theo"))
1235 );
1236 }
1237
1238 #[test]
1239 fn wind_spitz_basic_and_nennleistung_cap() {
1240 let mut input = WindSpitzInput {
1242 kf: dec(0.9),
1243 p_theo: dec(2000.0),
1244 p_mba: None,
1245 p_bean: None,
1246 p_lim: dec(400.0),
1247 p_nenn: dec(3000.0),
1248 };
1249 assert_eq!(wind_spitz(&input), dec(350.0));
1250 input.kf = dec(1.8);
1252 assert_eq!(wind_spitz(&input), dec(650.0));
1253 input.kf = dec(0.9);
1255 input.p_bean = Some(dec(1000.0));
1256 assert_eq!(wind_spitz(&input), dec(150.0));
1257 input.p_lim = dec(2500.0);
1259 assert_eq!(wind_spitz(&input), Decimal::ZERO);
1260 }
1261
1262 #[test]
1263 fn wind_pauschal_fortschreibung() {
1264 assert_eq!(
1266 wind_pauschal(dec(1200.0), dec(2000.0), None, None, dec(200.0)),
1267 dec(250.0)
1268 );
1269 assert_eq!(
1271 wind_pauschal(dec(2500.0), dec(2000.0), None, None, dec(200.0)),
1272 dec(450.0)
1273 );
1274 }
1275
1276 #[test]
1279 fn wind_bin_index_centres_on_half_ms() {
1280 assert_eq!(wind_bin_index(dec(0.0)), 0);
1281 assert_eq!(wind_bin_index(dec(7.6)), 15); assert_eq!(wind_bin_index(dec(7.74)), 15);
1283 assert_eq!(wind_bin_index(dec(7.8)), 16);
1284 }
1285
1286 #[test]
1287 fn kf_lbin_requires_three_wertepaare_and_clamps_at_zero() {
1288 assert_eq!(
1289 kf_lbin(&[dec(900.0), dec(950.0)], dec(1000.0)),
1290 Err(AusfallarbeitError::BinUnterbesetzt(2))
1291 );
1292 let kf = kf_lbin(&[dec(900.0), dec(950.0), dec(1000.0)], dec(1000.0)).unwrap();
1293 assert_eq!(kf, dec(0.95));
1294 let kf = kf_lbin(&[dec(-10.0), dec(-20.0), dec(-30.0)], dec(1000.0)).unwrap();
1296 assert_eq!(kf, Decimal::ZERO);
1297 }
1298
1299 #[test]
1300 fn kf_lbin_ersatzwert_chain_order() {
1301 assert_eq!(
1302 kf_lbin_ersatzwert(Some(dec(0.9)), Some(dec(0.8)), Some(dec(0.7))),
1303 (dec(0.9), KfLbinQuelle::Vormonat)
1304 );
1305 assert_eq!(
1306 kf_lbin_ersatzwert(None, Some(dec(0.8)), Some(dec(0.7))),
1307 (dec(0.8), KfLbinQuelle::Folgemonat)
1308 );
1309 assert_eq!(
1310 kf_lbin_ersatzwert(None, None, Some(dec(0.7))),
1311 (dec(0.7), KfLbinQuelle::ZwoelfMonatsMittel)
1312 );
1313 assert_eq!(
1314 kf_lbin_ersatzwert(None, None, None),
1315 (Decimal::ONE, KfLbinQuelle::Standard)
1316 );
1317 }
1318
1319 #[test]
1320 fn verlustfaktor_domain() {
1321 assert_eq!(verlustfaktor(dec(970.0), dec(1000.0)), Ok(dec(0.97)));
1322 assert!(matches!(
1324 verlustfaktor(dec(1000.0), dec(1000.0)),
1325 Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1326 ));
1327 assert!(matches!(
1328 verlustfaktor(dec(1100.0), dec(1000.0)),
1329 Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1330 ));
1331 assert!(matches!(
1332 verlustfaktor(dec(0.0), dec(1000.0)),
1333 Err(AusfallarbeitError::VerlustfaktorAusserhalb(_))
1334 ));
1335 }
1336
1337 #[test]
1338 fn wind_bin_composes_into_spitz_formula() {
1339 let kf = kf_bin(dec(0.95), dec(0.97));
1340 let input = WindSpitzInput {
1341 kf,
1342 p_theo: dec(1000.0),
1343 p_mba: None,
1344 p_bean: None,
1345 p_lim: dec(121.5),
1346 p_nenn: dec(5000.0),
1347 };
1348 assert_eq!(wind_spitz(&input), dec(200.0));
1350 }
1351
1352 #[test]
1355 fn solar_spitz_scales_by_irradiation() {
1356 let input = SolarSpitzInput {
1358 p_vz_ist: dec(800.0),
1359 g_vz: dec(0.4),
1360 g_i: dec(0.6),
1361 p_wr: dec(1500.0),
1362 p_mba: None,
1363 p_bean: None,
1364 p_lim: dec(200.0),
1365 p_nenn: dec(1400.0),
1366 };
1367 assert_eq!(solar_spitz(&input).unwrap(), dec(250.0));
1369 let engpass = SolarSpitzInput {
1371 p_wr: dec(1000.0),
1372 ..input
1373 };
1374 assert_eq!(solar_spitz(&engpass).unwrap(), dec(200.0));
1375 let kaputt = SolarSpitzInput {
1377 g_vz: Decimal::ZERO,
1378 ..input
1379 };
1380 assert_eq!(
1381 solar_spitz(&kaputt),
1382 Err(AusfallarbeitError::UnzulaessigerDivisor("G_VZ"))
1383 );
1384 }
1385
1386 #[test]
1387 fn anlagenfaktor_table_summer_winter() {
1388 assert_eq!(
1390 anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)),
1391 dec(0.6189)
1392 );
1393 assert_eq!(
1395 anlagenfaktor(date!(2027 - 06 - 15), time!(06:00)),
1396 dec(0.2456)
1397 );
1398 assert_eq!(
1400 anlagenfaktor(date!(2027 - 06 - 15), time!(09:00)),
1401 dec(0.6189)
1402 );
1403 assert_eq!(
1405 anlagenfaktor(date!(2027 - 06 - 15), time!(19:00)),
1406 Decimal::ZERO
1407 );
1408 assert_eq!(
1410 anlagenfaktor(date!(2027 - 01 - 15), time!(12:00)),
1411 dec(0.5030)
1412 );
1413 assert_eq!(
1414 anlagenfaktor(date!(2027 - 01 - 15), time!(09:15)),
1415 dec(0.2796)
1416 );
1417 assert_eq!(
1418 anlagenfaktor(date!(2027 - 01 - 15), time!(16:30)),
1419 dec(0.2796)
1420 );
1421 assert_eq!(
1422 anlagenfaktor(date!(2027 - 01 - 15), time!(16:45)),
1423 Decimal::ZERO
1424 );
1425 assert_eq!(
1427 anlagenfaktor(date!(2027 - 03 - 01), time!(12:00)),
1428 dec(0.6189)
1429 );
1430 assert_eq!(
1431 anlagenfaktor(date!(2027 - 11 - 01), time!(12:00)),
1432 dec(0.5030)
1433 );
1434 }
1435
1436 #[test]
1437 fn solar_pauschal_uses_af_and_wr() {
1438 let af = anlagenfaktor(date!(2027 - 06 - 15), time!(12:00)); assert_eq!(
1441 solar_pauschal(af, dec(1000.0), dec(600.0), None, None, dec(100.0)),
1442 dec(125.0)
1443 );
1444 }
1445
1446 #[test]
1449 fn nichtfluktuierend_spitz_sign_convention() {
1450 assert_eq!(
1452 nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(1000.0), dec(400.0)),
1453 dec(150.0)
1454 );
1455 assert_eq!(
1457 nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(400.0), dec(1000.0)),
1458 dec(-150.0)
1459 );
1460 assert_eq!(
1462 nichtfluktuierend_spitz(RedispatchRichtung::Positiv, dec(1000.0), dec(400.0)),
1463 Decimal::ZERO
1464 );
1465 assert_eq!(
1466 nichtfluktuierend_spitz(RedispatchRichtung::Negativ, dec(400.0), dec(1000.0)),
1467 Decimal::ZERO
1468 );
1469 }
1470
1471 #[test]
1472 fn nichtfluktuierend_pauschal_final_formulas() {
1473 assert_eq!(
1475 nichtfluktuierend_pauschal(
1476 RedispatchRichtung::Negativ,
1477 dec(1200.0),
1478 Some(dec(1100.0)),
1479 dec(300.0)
1480 ),
1481 dec(200.0)
1482 );
1483 assert_eq!(
1485 nichtfluktuierend_pauschal(
1486 RedispatchRichtung::Positiv,
1487 dec(400.0),
1488 Some(dec(800.0)),
1489 dec(1000.0)
1490 ),
1491 dec(-100.0)
1492 );
1493 assert_eq!(
1495 nichtfluktuierend_pauschal(RedispatchRichtung::Negativ, dec(1200.0), None, dec(300.0)),
1496 dec(225.0)
1497 );
1498 }
1499
1500 #[test]
1503 fn ueberbauung_no_cut_when_under_cap() {
1504 let trs = [
1505 UeberbauungTr {
1506 w_a_kwh: dec(100.0),
1507 p_inst_kw: dec(2000.0),
1508 },
1509 UeberbauungTr {
1510 w_a_kwh: dec(50.0),
1511 p_inst_kw: dec(1000.0),
1512 },
1513 ];
1514 let out = ueberbauung_kuerzung(&trs, dec(1000.0), dec(50.0)).unwrap();
1516 assert_eq!(out, vec![dec(100.0), dec(50.0)]);
1517 }
1518
1519 #[test]
1520 fn ueberbauung_pro_rata_by_installed_capacity() {
1521 let trs = [
1522 UeberbauungTr {
1523 w_a_kwh: dec(100.0),
1524 p_inst_kw: dec(2000.0),
1525 },
1526 UeberbauungTr {
1527 w_a_kwh: dec(50.0),
1528 p_inst_kw: dec(1000.0),
1529 },
1530 ];
1531 let out = ueberbauung_kuerzung(&trs, dec(400.0), dec(10.0)).unwrap();
1533 assert_eq!(out, vec![dec(60.0), dec(30.0)]);
1534 assert_eq!(out.iter().copied().sum::<Decimal>(), dec(90.0));
1535 }
1536
1537 #[test]
1538 fn ueberbauung_clamps_at_zero_and_redistributes() {
1539 let trs = [
1540 UeberbauungTr {
1541 w_a_kwh: dec(10.0),
1542 p_inst_kw: dec(3000.0),
1543 },
1544 UeberbauungTr {
1545 w_a_kwh: dec(200.0),
1546 p_inst_kw: dec(1000.0),
1547 },
1548 ];
1549 let out = ueberbauung_kuerzung(&trs, dec(400.0), Decimal::ZERO).unwrap();
1552 assert_eq!(out[0], Decimal::ZERO);
1553 assert_eq!(out[1], dec(100.0));
1554 assert_eq!(out.iter().copied().sum::<Decimal>(), dec(100.0));
1555 }
1556
1557 #[test]
1560 fn malo_split_pro_rata() {
1561 let out = malo_wert_auf_tr(dec(900.0), &[dec(2000.0), dec(1000.0)]).unwrap();
1562 assert_eq!(out, vec![dec(600.0), dec(300.0)]);
1563 assert!(malo_wert_auf_tr(dec(900.0), &[]).is_err());
1564 }
1565}