use rust_decimal::Decimal;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[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 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_scaled_above_threshold(self) -> bool {
matches!(self, Self::Waermepumpe | Self::Raumkuehlung)
}
}
crate::codes::string_codes! {
SteuVeFallgruppe;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SteuVe {
pub fallgruppe: SteuVeFallgruppe,
pub netzanschlussleistung_kw: Decimal,
}
impl SteuVe {
#[must_use]
pub const fn new(fallgruppe: SteuVeFallgruppe, netzanschlussleistung_kw: Decimal) -> Self {
Self {
fallgruppe,
netzanschlussleistung_kw,
}
}
#[must_use]
pub fn is_steuerbar(&self) -> bool {
self.netzanschlussleistung_kw > STEUVE_SCHWELLE_KW
}
}
pub const MINDESTLEISTUNG_KW: Decimal = Decimal::from_parts(42, 0, 0, false, 1);
pub const STEUVE_SCHWELLE_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),
];
#[must_use]
pub fn gleichzeitigkeitsfaktor(n_steuve: u32) -> Option<Decimal> {
if n_steuve < 2 {
return None;
}
let index = (n_steuve as usize - 2).min(GLEICHZEITIGKEITSFAKTOREN.len() - 1);
GLEICHZEITIGKEITSFAKTOREN.get(index).copied()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Para14aConfig {
pub mindestleistung_kw: Decimal,
pub skalierung_schwelle_kw: Decimal,
pub skalierungsfaktor: Decimal,
}
impl Default for Para14aConfig {
fn default() -> Self {
Self {
mindestleistung_kw: MINDESTLEISTUNG_KW,
skalierung_schwelle_kw: SKALIERUNG_SCHWELLE_KW,
skalierungsfaktor: SKALIERUNGSFAKTOR,
}
}
}
impl Para14aConfig {
#[must_use]
fn is_scaled(&self, device: &SteuVe) -> bool {
device.fallgruppe.is_scaled_above_threshold()
&& device.netzanschlussleistung_kw > self.skalierung_schwelle_kw
}
}
#[must_use]
pub fn mindestleistung_direktansteuerung(
device: &SteuVe,
config: &Para14aConfig,
) -> Option<Decimal> {
if !device.is_steuerbar() {
return None;
}
Some(if config.is_scaled(device) {
device.netzanschlussleistung_kw * config.skalierungsfaktor
} else {
config.mindestleistung_kw
})
}
#[must_use]
pub fn mindestleistung_ems(devices: &[SteuVe], config: &Para14aConfig) -> Option<Decimal> {
let n = u32::try_from(devices.len()).ok()?;
if n == 0 || !devices.iter().all(SteuVe::is_steuerbar) {
return None;
}
let sum_of = |gruppe: SteuVeFallgruppe| -> Decimal {
devices
.iter()
.filter(|d| d.fallgruppe == gruppe)
.map(|d| d.netzanschlussleistung_kw)
.sum()
};
let base = if devices.iter().any(|d| config.is_scaled(d)) {
let wp = config.skalierungsfaktor * sum_of(SteuVeFallgruppe::Waermepumpe);
let klima = config.skalierungsfaktor * sum_of(SteuVeFallgruppe::Raumkuehlung);
wp.max(klima)
} else {
config.mindestleistung_kw
};
let rest = match gleichzeitigkeitsfaktor(n) {
Some(gzf) => Decimal::from(n - 1) * gzf * config.mindestleistung_kw,
None => Decimal::ZERO,
};
Some(base + rest)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Verursachungsregel {
#[cfg_attr(feature = "serde", serde(rename = "STEUVE_ZULETZT"))]
#[default]
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::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 + uebrige;
if gesamt.is_zero() {
return Some(Decimal::ZERO);
}
Some((netzbezug * steuve / gesamt).min(steuve))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use SteuVeFallgruppe as F;
use rust_decimal::dec;
fn cfg() -> Para14aConfig {
Para14aConfig::default()
}
#[test]
fn only_heat_pumps_and_cooling_scale_above_eleven_kilowatts() {
for gruppe in [F::Ladepunkt, F::Stromspeicher] {
let big = SteuVe::new(gruppe, dec!(50));
assert_eq!(
mindestleistung_direktansteuerung(&big, &cfg()),
Some(dec!(4.2)),
"{gruppe} must not scale"
);
}
for gruppe in [F::Waermepumpe, F::Raumkuehlung] {
let big = SteuVe::new(gruppe, dec!(50));
assert_eq!(
mindestleistung_direktansteuerung(&big, &cfg()),
Some(dec!(20.0))
);
let at = SteuVe::new(gruppe, dec!(11));
assert_eq!(
mindestleistung_direktansteuerung(&at, &cfg()),
Some(dec!(4.2))
);
}
}
#[test]
fn a_device_below_the_threshold_is_not_a_steuve() {
let tiny = SteuVe::new(F::Waermepumpe, dec!(3));
let boundary = SteuVe::new(F::Ladepunkt, dec!(4.2));
let real = SteuVe::new(F::Ladepunkt, dec!(11));
assert!(!tiny.is_steuerbar());
assert!(!boundary.is_steuerbar(), "\"mehr als 4,2 kW\" is strict");
assert!(real.is_steuerbar());
assert_eq!(mindestleistung_direktansteuerung(&tiny, &cfg()), None);
assert_eq!(mindestleistung_direktansteuerung(&boundary, &cfg()), None);
assert_eq!(mindestleistung_ems(&[real, tiny], &cfg()), None);
assert_eq!(
mindestleistung_ems(&[real], &cfg()),
Some(dec!(4.2)),
"the qualifying device alone still answers",
);
assert_ne!(mindestleistung_ems(&[real, tiny], &cfg()), Some(dec!(7.56)));
}
#[test]
fn the_flat_branch_matches_the_published_formula() {
let cases = [
(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)),
];
for (n, expected) in cases {
let devices = vec![SteuVe::new(F::Ladepunkt, dec!(11)); n];
assert_eq!(
mindestleistung_ems(&devices, &cfg()),
Some(expected),
"n = {n}"
);
}
}
#[test]
fn the_scaled_branch_takes_the_maximum_of_the_two_group_sums() {
let devices = [
SteuVe::new(F::Waermepumpe, dec!(20)), SteuVe::new(F::Raumkuehlung, dec!(15)), ];
let gzf = dec!(0.80);
let expected = dec!(8.0) + dec!(1) * gzf * dec!(4.2);
assert_eq!(mindestleistung_ems(&devices, &cfg()), Some(expected));
let wrong = dec!(14.0) + dec!(1) * gzf * dec!(4.2);
assert_ne!(mindestleistung_ems(&devices, &cfg()), Some(wrong));
}
#[test]
fn the_sums_cover_the_whole_group_but_the_branch_needs_one_over_the_threshold() {
let ungrouped = vec![SteuVe::new(F::Waermepumpe, dec!(5)); 3];
let flat = dec!(4.2) + dec!(2) * dec!(0.75) * dec!(4.2);
assert_eq!(mindestleistung_ems(&ungrouped, &cfg()), Some(flat));
let grouped = [SteuVe::new(F::Waermepumpe, dec!(15))];
assert_eq!(mindestleistung_ems(&grouped, &cfg()), Some(dec!(6.0)));
let mixed = [
SteuVe::new(F::Waermepumpe, dec!(20)),
SteuVe::new(F::Waermepumpe, dec!(5)),
];
let expected = dec!(0.4) * dec!(25) + dec!(1) * dec!(0.80) * dec!(4.2);
assert_eq!(mindestleistung_ems(&mixed, &cfg()), Some(expected));
}
#[test]
fn an_empty_set_has_no_floor() {
assert_eq!(mindestleistung_ems(&[], &cfg()), None);
}
#[test]
fn the_gleichzeitigkeitsfaktor_table_is_the_published_one() {
let published = [
(2u32, 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)),
];
for (n, gzf) in published {
assert_eq!(gleichzeitigkeitsfaktor(n), Some(gzf), "n = {n}");
}
for n in [10u32, 25, 1_000] {
assert_eq!(gleichzeitigkeitsfaktor(n), Some(dec!(0.45)), "n = {n}");
}
assert_eq!(gleichzeitigkeitsfaktor(0), None);
assert_eq!(gleichzeitigkeitsfaktor(1), None);
}
#[test]
fn the_ems_floor_is_monotone_in_the_device_count() {
let mut previous = Decimal::ZERO;
for n in 1..=12usize {
let devices = vec![SteuVe::new(F::Ladepunkt, dec!(11)); n];
let floor = mindestleistung_ems(&devices, &cfg()).expect("non-empty");
assert!(floor >= previous, "n = {n}: {floor} < {previous}");
assert!(floor >= dec!(4.2), "n = {n}: below the single-device floor");
previous = floor;
}
}
#[test]
fn the_conservative_convention_never_understates_the_share() {
use Verursachungsregel as R;
let cases = [
(dec!(10), dec!(6), dec!(8)),
(dec!(3), dec!(6), dec!(2)),
(dec!(0), dec!(6), dec!(8)),
(dec!(14), dec!(6), dec!(8)),
];
for (netz, steuve, uebrige) in cases {
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 devices = [
SteuVe::new(F::Ladepunkt, dec!(11)),
SteuVe::new(F::Waermepumpe, dec!(20)),
];
let floor = mindestleistung_ems(&devices, &cfg()).unwrap();
assert_eq!(floor, dec!(11.360));
let drawn = netzwirksamer_leistungsbezug(
dec!(31),
dec!(31),
None,
Verursachungsregel::SteuVeZuletzt,
)
.unwrap();
assert!(drawn > floor, "there is headroom to reduce");
assert_eq!(drawn - floor, dec!(19.640), "the abregelbare Leistung");
}
}