use rust_decimal::Decimal;
use time::{Date, Weekday};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::holiday::{Bundesland, Holiday};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SigLinDe {
pub a: f64,
pub b: f64,
pub c: f64,
pub d: f64,
pub theta0: f64,
pub m_h: f64,
pub b_h: f64,
pub m_w: f64,
pub b_w: f64,
}
impl SigLinDe {
pub const H_VALUE_DP: u32 = 6;
pub const DE_HEF34: Self = Self {
a: 1.381_966_3,
b: -37.412_415_5,
c: 6.172_317_9,
d: 0.039_628_4,
theta0: 40.0,
m_h: -0.067_215_9,
b_h: 1.116_713_8,
m_w: -0.001_998_2,
b_w: 0.135_507_0,
};
#[must_use]
pub const fn pure_sigmoid(a: f64, b: f64, c: f64, d: f64) -> Self {
Self {
a,
b,
c,
d,
theta0: 40.0,
m_h: 0.0,
b_h: 0.0,
m_w: 0.0,
b_w: 0.0,
}
}
#[must_use]
pub fn h(&self, theta_c: f64) -> f64 {
let sigmoid = if theta_c < self.theta0 {
let ratio = self.b / (theta_c - self.theta0);
if ratio > 0.0 && ratio.is_finite() {
self.a / (1.0 + ratio.powf(self.c)) + self.d
} else {
self.d
}
} else {
self.d
};
let linear = (self.m_h * theta_c + self.b_h).max(self.m_w * theta_c + self.b_w);
(sigmoid + linear).max(0.0)
}
#[must_use]
pub fn h_value(&self, theta_c: Decimal) -> Decimal {
use rust_decimal::prelude::ToPrimitive as _;
let theta = theta_c.to_f64().unwrap_or(0.0);
Decimal::try_from(self.h(theta))
.unwrap_or(Decimal::ZERO)
.round_dp(Self::H_VALUE_DP)
}
}
#[must_use]
pub fn allocation_temperature(
t_d: Decimal,
t_d1: Decimal,
t_d2: Decimal,
t_d3: Decimal,
) -> Decimal {
let eight = Decimal::from(8u32);
let four = Decimal::from(4u32);
let two = Decimal::from(2u32);
(eight * t_d + four * t_d1 + two * t_d2 + t_d3) / Decimal::from(15u32)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WeekdayFactors {
factors: [Decimal; 7],
}
impl WeekdayFactors {
pub const NONE: Self = Self {
factors: [Decimal::ONE; 7],
};
#[must_use]
pub fn new(factors: [Decimal; 7]) -> Option<Self> {
let sum: Decimal = factors.iter().copied().sum();
(sum == Decimal::from(7u32)).then_some(Self { factors })
}
#[must_use]
pub fn factor(&self, weekday: Weekday) -> Decimal {
self.factors[weekday.number_days_from_monday() as usize]
}
#[must_use]
pub fn for_date(&self, date: Date, land: Option<Bundesland>) -> Decimal {
let is_holiday = match land {
Some(land) => land.is_holiday(date),
None => Holiday::on(date).any(Holiday::is_nationwide),
};
if is_holiday {
self.factor(Weekday::Sunday)
} else {
self.factor(date.weekday())
}
}
}
pub const KUNDENWERT_DP: u32 = 4;
#[must_use]
pub fn kundenwert(measured_kwh: Decimal, h_times_f_sum: Decimal) -> Option<Decimal> {
if h_times_f_sum <= Decimal::ZERO {
return None;
}
Some((measured_kwh / h_times_f_sum).round_dp(KUNDENWERT_DP))
}
#[must_use]
pub fn gas_daily_quantity(kundenwert: Decimal, h: Decimal, weekday_factor: Decimal) -> Decimal {
kundenwert * h * weekday_factor
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
#[test]
fn de_hef34_reproduces_the_published_normalisation() {
let h = SigLinDe::DE_HEF34.h(8.0);
assert!(
(h - 1.0).abs() < 5e-5,
"h(8 °C) must be 1.00000 per the datasheet, got {h}"
);
assert_eq!(SigLinDe::DE_HEF34.h_value(dec!(8)).round_dp(4), dec!(1));
}
#[test]
fn the_profile_falls_with_temperature_but_not_to_zero() {
let p = SigLinDe::DE_HEF34;
let mut last = f64::INFINITY;
for theta in [-15.0, -10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0, 25.0] {
let h = p.h(theta);
assert!(h < last, "h must fall with rising temperature at {theta}");
assert!(h > 0.0, "h must stay positive at {theta}");
last = h;
}
assert!(p.h(25.0) < 0.2, "got {}", p.h(25.0));
assert!(p.h(-15.0) > 2.0, "got {}", p.h(-15.0));
}
#[test]
fn temperatures_at_and_above_the_reference_are_defined() {
let p = SigLinDe::DE_HEF34;
for theta in [39.999, 40.0, 45.0] {
let h = p.h(theta);
assert!(h.is_finite(), "h({theta}) must be finite, got {h}");
assert!(h >= 0.0);
}
assert_eq!(p.h(500.0), 0.0);
}
#[test]
fn a_pure_sigmoid_has_no_linear_share() {
let tum = SigLinDe::pure_sigmoid(3.055_384_2, -36.965_006_5, 7.225_694_7, 0.044_841_6);
assert!((tum.h(35.0) - tum.d).abs() < 1e-3);
assert!((tum.h(-35.0) - (tum.a + tum.d)).abs() < 0.05);
}
#[test]
fn the_allocation_temperature_is_the_geometric_series() {
assert_eq!(
allocation_temperature(dec!(5), dec!(5), dec!(5), dec!(5)),
dec!(5)
);
assert_eq!(
allocation_temperature(dec!(5), dec!(2.5), dec!(2.5), dec!(5)),
dec!(4)
);
let cold_snap = allocation_temperature(dec!(-10), dec!(0), dec!(10), dec!(10));
assert!(cold_snap < dec!(-2), "got {cold_snap}");
}
#[test]
fn weekday_factors_must_sum_to_seven() {
assert!(WeekdayFactors::new([Decimal::ONE; 7]).is_some());
assert!(
WeekdayFactors::new([
dec!(1.0203),
dec!(1.0253),
dec!(1.0303),
dec!(1.0253),
dec!(1.0253),
dec!(0.9500),
dec!(0.9235),
])
.is_some(),
"a realistic Gewerbe set summing to 7.0000"
);
assert!(
WeekdayFactors::new([dec!(1.1); 7]).is_none(),
"7.7 is not a standard week"
);
}
#[test]
fn holidays_take_the_sunday_factor() {
let factors = WeekdayFactors::new([
dec!(1.0203),
dec!(1.0253),
dec!(1.0303),
dec!(1.0253),
dec!(1.0253),
dec!(0.9500),
dec!(0.9235),
])
.unwrap();
assert_eq!(factors.for_date(date!(2026 - 06 - 11), None), dec!(1.0253));
assert_eq!(factors.for_date(date!(2026 - 05 - 14), None), dec!(0.9235));
let fronleichnam = date!(2026 - 06 - 04);
assert_eq!(factors.for_date(fronleichnam, None), dec!(1.0253));
assert_eq!(
factors.for_date(fronleichnam, Some(Bundesland::By)),
dec!(0.9235)
);
assert_eq!(
WeekdayFactors::NONE.for_date(fronleichnam, Some(Bundesland::By)),
Decimal::ONE
);
}
#[test]
fn kundenwert_follows_the_leitfaden_definition() {
let kw = kundenwert(dec!(8500), dec!(140.8654)).unwrap();
assert_eq!(kw, dec!(60.3413), "8500 / 140.8654 to four places");
assert_eq!(kundenwert(dec!(8500), Decimal::ZERO), None);
assert_eq!(kundenwert(dec!(8500), dec!(-1)), None);
}
#[test]
fn the_daily_quantity_reconstructs_the_reference_consumption() {
let p = SigLinDe::DE_HEF34;
let temps = [dec!(-2), dec!(1.5), dec!(4), dec!(9), dec!(13.5)];
let hf_sum: Decimal = temps.iter().map(|&t| p.h_value(t)).sum();
let measured = dec!(500);
let kw = kundenwert(measured, hf_sum).unwrap();
let reallocated: Decimal = temps
.iter()
.map(|&t| gas_daily_quantity(kw, p.h_value(t), Decimal::ONE))
.sum();
let error = (reallocated - measured).abs();
assert!(
error < dec!(0.01),
"round trip must close to within rounding: {reallocated} vs {measured}"
);
}
}