use rust_decimal::Decimal;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SteuVeFallgruppe {
Ladepunkt,
Waermepumpe,
Raumkuehlung,
Stromspeicher,
}
impl SteuVeFallgruppe {
pub const ALL: [Self; 4] = [
Self::Ladepunkt,
Self::Waermepumpe,
Self::Raumkuehlung,
Self::Stromspeicher,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ladepunkt => "LADEPUNKT",
Self::Waermepumpe => "WAERMEPUMPE",
Self::Raumkuehlung => "RAUMKUEHLUNG",
Self::Stromspeicher => "STROMSPEICHER",
}
}
#[must_use]
pub const fn ziffer(self) -> &'static str {
match self {
Self::Ladepunkt => "2.4.1.a",
Self::Waermepumpe => "2.4.1.b",
Self::Raumkuehlung => "2.4.1.c",
Self::Stromspeicher => "2.4.1.d",
}
}
#[must_use]
pub const fn is_grouped_and_scaled(self) -> bool {
matches!(self, Self::Waermepumpe | Self::Raumkuehlung)
}
}
crate::ids::codes::string_codes! {
SteuVeFallgruppe;
}
pub const STEUVE_SCHWELLE_KW: Decimal = Decimal::from_parts(42, 0, 0, false, 1);
pub const MINDESTLEISTUNG_KW: Decimal = Decimal::from_parts(42, 0, 0, false, 1);
pub const SKALIERUNG_SCHWELLE_KW: Decimal = Decimal::from_parts(11, 0, 0, false, 0);
pub const SKALIERUNGSFAKTOR: Decimal = Decimal::from_parts(4, 0, 0, false, 1);
pub const GLEICHZEITIGKEITSFAKTOREN: [Decimal; 8] = [
Decimal::from_parts(80, 0, 0, false, 2),
Decimal::from_parts(75, 0, 0, false, 2),
Decimal::from_parts(70, 0, 0, false, 2),
Decimal::from_parts(65, 0, 0, false, 2),
Decimal::from_parts(60, 0, 0, false, 2),
Decimal::from_parts(55, 0, 0, false, 2),
Decimal::from_parts(50, 0, 0, false, 2),
Decimal::from_parts(45, 0, 0, false, 2),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct Para14aConfig {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
skalierungsfaktor: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_array"))]
gleichzeitigkeitsfaktoren: [Decimal; 8],
}
impl Para14aConfig {
pub const VERMUTUNG: Self = Self {
skalierungsfaktor: SKALIERUNGSFAKTOR,
gleichzeitigkeitsfaktoren: GLEICHZEITIGKEITSFAKTOREN,
};
#[must_use]
pub fn skalierungsfaktor(mut self, factor: Decimal) -> Option<Self> {
(factor > Decimal::ZERO && factor <= Decimal::ONE).then(|| {
self.skalierungsfaktor = factor;
self
})
}
#[must_use]
pub fn gleichzeitigkeitsfaktoren(mut self, table: [Decimal; 8]) -> Option<Self> {
table
.iter()
.all(|g| *g > Decimal::ZERO && *g <= Decimal::ONE)
.then(|| {
self.gleichzeitigkeitsfaktoren = table;
self
})
}
#[must_use]
pub fn gleichzeitigkeitsfaktor(&self, n_steuve: usize) -> Option<Decimal> {
let index = n_steuve
.checked_sub(2)?
.min(self.gleichzeitigkeitsfaktoren.len() - 1);
self.gleichzeitigkeitsfaktoren.get(index).copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Anlage {
pub fallgruppe: SteuVeFallgruppe,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub netzanschlussleistung_kw: Decimal,
}
impl Anlage {
#[must_use]
pub const fn new(fallgruppe: SteuVeFallgruppe, netzanschlussleistung_kw: Decimal) -> Self {
Self {
fallgruppe,
netzanschlussleistung_kw,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize))]
pub struct SteuVe {
fallgruppe: SteuVeFallgruppe,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
netzanschlussleistung_kw: Decimal,
}
impl SteuVe {
#[must_use]
pub const fn fallgruppe(&self) -> SteuVeFallgruppe {
self.fallgruppe
}
#[must_use]
pub const fn netzanschlussleistung_kw(&self) -> Decimal {
self.netzanschlussleistung_kw
}
#[must_use]
pub fn is_scaled(&self) -> bool {
self.fallgruppe.is_grouped_and_scaled()
&& self.netzanschlussleistung_kw > SKALIERUNG_SCHWELLE_KW
}
}
#[must_use]
pub fn steuerbare_verbrauchseinrichtungen(anlagen: &[Anlage]) -> Option<Vec<SteuVe>> {
let mut out = Vec::new();
for gruppe in [
SteuVeFallgruppe::Waermepumpe,
SteuVeFallgruppe::Raumkuehlung,
] {
let sum = anlagen
.iter()
.filter(|a| a.fallgruppe == gruppe)
.try_fold(Decimal::ZERO, |s, a| {
s.checked_add(a.netzanschlussleistung_kw)
})?;
if sum > STEUVE_SCHWELLE_KW {
out.push(SteuVe {
fallgruppe: gruppe,
netzanschlussleistung_kw: sum,
});
}
}
out.extend(
anlagen
.iter()
.filter(|a| {
!a.fallgruppe.is_grouped_and_scaled()
&& a.netzanschlussleistung_kw > STEUVE_SCHWELLE_KW
})
.map(|a| SteuVe {
fallgruppe: a.fallgruppe,
netzanschlussleistung_kw: a.netzanschlussleistung_kw,
}),
);
Some(out)
}
#[must_use]
pub fn mindestleistung_direktansteuerung(steuve: &SteuVe, config: &Para14aConfig) -> Decimal {
if steuve.is_scaled() {
steuve.netzanschlussleistung_kw * config.skalierungsfaktor
} else {
MINDESTLEISTUNG_KW
}
}
#[must_use]
pub fn mindestleistung_ems(anlagen: &[Anlage], config: &Para14aConfig) -> Option<Decimal> {
let steuve = steuerbare_verbrauchseinrichtungen(anlagen)?;
let n = steuve.len();
if n == 0 {
return None;
}
let sum_of = |gruppe: SteuVeFallgruppe| -> Option<Decimal> {
steuve
.iter()
.filter(|s| s.fallgruppe == gruppe)
.try_fold(Decimal::ZERO, |acc, s| {
acc.checked_add(s.netzanschlussleistung_kw)
})
};
let base = if steuve.iter().any(SteuVe::is_scaled) {
let wp = sum_of(SteuVeFallgruppe::Waermepumpe)?.checked_mul(config.skalierungsfaktor)?;
let klima =
sum_of(SteuVeFallgruppe::Raumkuehlung)?.checked_mul(config.skalierungsfaktor)?;
wp.max(klima)
} else {
MINDESTLEISTUNG_KW
};
let rest = match config.gleichzeitigkeitsfaktor(n) {
Some(gzf) => Decimal::from(n - 1)
.checked_mul(gzf)?
.checked_mul(MINDESTLEISTUNG_KW)?,
None => Decimal::ZERO,
};
base.checked_add(rest)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Verursachungsregel {
SteuVeZuletzt,
Anteilig,
}
impl Verursachungsregel {
pub const ALL: [Self; 2] = [Self::SteuVeZuletzt, Self::Anteilig];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::SteuVeZuletzt => "STEUVE_ZULETZT",
Self::Anteilig => "ANTEILIG",
}
}
}
crate::ids::codes::string_codes! {
Verursachungsregel;
}
#[must_use]
pub fn netzwirksamer_leistungsbezug(
netzbezug_kw: Decimal,
steuve_kw: Decimal,
uebrige_last_kw: Option<Decimal>,
regel: Verursachungsregel,
) -> Option<Decimal> {
let netzbezug = netzbezug_kw.max(Decimal::ZERO);
let steuve = steuve_kw.max(Decimal::ZERO);
match regel {
Verursachungsregel::SteuVeZuletzt => Some(netzbezug.min(steuve)),
Verursachungsregel::Anteilig => {
let uebrige = uebrige_last_kw?.max(Decimal::ZERO);
let gesamt = steuve.checked_add(uebrige)?;
if gesamt.is_zero() {
return Some(Decimal::ZERO);
}
Some(
netzbezug
.checked_mul(steuve)?
.checked_div(gesamt)?
.min(steuve),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use SteuVeFallgruppe as F;
use rust_decimal::dec;
const CFG: Para14aConfig = Para14aConfig::VERMUTUNG;
fn steuve(anlagen: &[Anlage]) -> Vec<SteuVe> {
steuerbare_verbrauchseinrichtungen(anlagen).unwrap()
}
#[test]
fn three_five_kilowatt_heat_pumps_are_one_steuve_of_fifteen() {
let anlagen = [Anlage::new(F::Waermepumpe, dec!(5)); 3];
let s = steuve(&anlagen);
assert_eq!(s.len(), 1);
assert_eq!(s[0].netzanschlussleistung_kw(), dec!(15));
assert_eq!(mindestleistung_ems(&anlagen, &CFG), Some(dec!(6.0)));
assert_ne!(mindestleistung_ems(&anlagen, &CFG), Some(dec!(10.50)));
}
#[test]
fn two_three_kilowatt_heat_pumps_are_one_steuve_of_six() {
let anlagen = [Anlage::new(F::Waermepumpe, dec!(3)); 2];
let s = steuve(&anlagen);
assert_eq!(s.len(), 1);
assert_eq!(s[0].netzanschlussleistung_kw(), dec!(6));
assert_eq!(mindestleistung_ems(&anlagen, &CFG), Some(dec!(4.2)));
assert_eq!(mindestleistung_direktansteuerung(&s[0], &CFG), dec!(4.2));
}
#[test]
fn grouping_is_per_fallgruppe_and_only_for_b_and_c() {
let s = steuve(&[
Anlage::new(F::Waermepumpe, dec!(3)),
Anlage::new(F::Raumkuehlung, dec!(3)),
Anlage::new(F::Ladepunkt, dec!(3)),
Anlage::new(F::Ladepunkt, dec!(3)),
Anlage::new(F::Stromspeicher, dec!(4.2)),
]);
assert!(s.is_empty(), "no group and no single device exceeds 4,2 kW");
assert_eq!(
mindestleistung_ems(&[Anlage::new(F::Ladepunkt, dec!(4.2))], &CFG),
None,
"\"mehr als 4,2 kW\" is strict"
);
let s = steuve(&[
Anlage::new(F::Ladepunkt, dec!(11)),
Anlage::new(F::Waermepumpe, dec!(2.5)),
Anlage::new(F::Raumkuehlung, dec!(4)),
Anlage::new(F::Waermepumpe, dec!(2.5)),
Anlage::new(F::Raumkuehlung, dec!(1)),
]);
let shape: Vec<_> = s
.iter()
.map(|s| (s.fallgruppe(), s.netzanschlussleistung_kw()))
.collect();
assert_eq!(
shape,
vec![
(F::Waermepumpe, dec!(5.0)),
(F::Raumkuehlung, dec!(5)),
(F::Ladepunkt, dec!(11)),
]
);
}
#[test]
fn only_heat_pumps_and_cooling_scale_above_eleven_kilowatts() {
for gruppe in [F::Ladepunkt, F::Stromspeicher] {
let s = steuve(&[Anlage::new(gruppe, dec!(50))]);
assert_eq!(mindestleistung_direktansteuerung(&s[0], &CFG), dec!(4.2));
}
for gruppe in [F::Waermepumpe, F::Raumkuehlung] {
let s = steuve(&[Anlage::new(gruppe, dec!(50))]);
assert_eq!(mindestleistung_direktansteuerung(&s[0], &CFG), dec!(20.0));
let at = steuve(&[Anlage::new(gruppe, dec!(11))]);
assert_eq!(mindestleistung_direktansteuerung(&at[0], &CFG), dec!(4.2));
}
}
#[test]
fn the_flat_branch_matches_the_published_formula() {
for (n, expected) in [
(1usize, dec!(4.2)),
(2, dec!(4.2) + dec!(1) * dec!(0.80) * dec!(4.2)),
(5, dec!(4.2) + dec!(4) * dec!(0.65) * dec!(4.2)),
(9, dec!(4.2) + dec!(8) * dec!(0.45) * dec!(4.2)),
(12, dec!(4.2) + dec!(11) * dec!(0.45) * dec!(4.2)),
] {
let anlagen = vec![Anlage::new(F::Ladepunkt, dec!(11)); n];
assert_eq!(
mindestleistung_ems(&anlagen, &CFG),
Some(expected),
"n = {n}"
);
}
}
#[test]
fn the_scaled_branch_takes_the_maximum_of_the_two_group_sums() {
let anlagen = [
Anlage::new(F::Waermepumpe, dec!(20)),
Anlage::new(F::Raumkuehlung, dec!(15)),
Anlage::new(F::Ladepunkt, dec!(11)),
];
let expected = dec!(8.0) + dec!(2) * dec!(0.75) * dec!(4.2);
assert_eq!(mindestleistung_ems(&anlagen, &CFG), Some(expected));
}
#[test]
fn the_group_sum_includes_every_heat_pump() {
let anlagen = [
Anlage::new(F::Waermepumpe, dec!(20)),
Anlage::new(F::Waermepumpe, dec!(5)),
];
assert_eq!(mindestleistung_ems(&anlagen, &CFG), Some(dec!(10.0)));
}
#[test]
fn the_gleichzeitigkeitsfaktor_table_is_the_published_one() {
for (n, gzf) in [
(2usize, dec!(0.80)),
(3, dec!(0.75)),
(4, dec!(0.70)),
(5, dec!(0.65)),
(6, dec!(0.60)),
(7, dec!(0.55)),
(8, dec!(0.50)),
(9, dec!(0.45)),
(1_000, dec!(0.45)),
] {
assert_eq!(CFG.gleichzeitigkeitsfaktor(n), Some(gzf), "n = {n}");
}
assert_eq!(CFG.gleichzeitigkeitsfaktor(0), None);
assert_eq!(CFG.gleichzeitigkeitsfaktor(1), None);
}
#[test]
fn the_presumptions_can_be_replaced_within_bounds() {
let half = CFG.skalierungsfaktor(dec!(0.5)).unwrap();
let s = steuve(&[Anlage::new(F::Waermepumpe, dec!(20))]);
assert_eq!(mindestleistung_direktansteuerung(&s[0], &half), dec!(10.0));
assert!(CFG.skalierungsfaktor(dec!(0)).is_none());
assert!(CFG.skalierungsfaktor(dec!(1.1)).is_none());
assert!(CFG.gleichzeitigkeitsfaktoren([dec!(0.5); 8]).is_some());
assert!(CFG.gleichzeitigkeitsfaktoren([dec!(-0.5); 8]).is_none());
}
#[test]
fn the_conservative_convention_never_understates_the_share() {
use Verursachungsregel as R;
for (netz, steuve, uebrige) in [
(dec!(10), dec!(6), dec!(8)),
(dec!(3), dec!(6), dec!(2)),
(dec!(0), dec!(6), dec!(8)),
(dec!(14), dec!(6), dec!(8)),
] {
let a = netzwirksamer_leistungsbezug(netz, steuve, None, R::SteuVeZuletzt).unwrap();
let b = netzwirksamer_leistungsbezug(netz, steuve, Some(uebrige), R::Anteilig).unwrap();
assert!(a >= b, "{netz}/{steuve}/{uebrige}: {a} < {b}");
for share in [a, b] {
assert!(share <= steuve && share <= netz.max(Decimal::ZERO));
assert!(share >= Decimal::ZERO);
}
}
}
#[test]
fn an_exporting_installation_draws_nothing_from_the_grid() {
use Verursachungsregel as R;
assert_eq!(
netzwirksamer_leistungsbezug(dec!(-4), dec!(6), None, R::SteuVeZuletzt),
Some(Decimal::ZERO),
);
assert_eq!(
netzwirksamer_leistungsbezug(dec!(-4), dec!(6), Some(dec!(8)), R::Anteilig),
Some(Decimal::ZERO),
);
}
#[test]
fn pro_rata_refuses_to_guess_the_rest_of_the_installation() {
assert_eq!(
netzwirksamer_leistungsbezug(dec!(10), dec!(6), None, Verursachungsregel::Anteilig),
None,
);
assert_eq!(
netzwirksamer_leistungsbezug(
dec!(0),
dec!(0),
Some(dec!(0)),
Verursachungsregel::Anteilig
),
Some(Decimal::ZERO),
);
}
#[test]
fn a_reduction_is_bounded_below_by_the_mindestleistung() {
let anlagen = [
Anlage::new(F::Ladepunkt, dec!(11)),
Anlage::new(F::Waermepumpe, dec!(20)),
];
let floor = mindestleistung_ems(&anlagen, &CFG).unwrap();
assert_eq!(floor, dec!(11.360));
let drawn = netzwirksamer_leistungsbezug(
dec!(31),
dec!(31),
None,
Verursachungsregel::SteuVeZuletzt,
)
.unwrap();
assert_eq!(drawn - floor, dec!(19.640), "the abregelbare Leistung");
}
}