use rust_decimal::{Decimal, dec};
use time::{Date, Weekday};
use crate::precision::{KUNDENWERT_DP, KUNDENWERT_STRATEGY};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::time::holiday::{Bundesland, Holiday};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum GasProfile {
Hef,
Hmf,
Hko,
Gko,
Gha,
Gmk,
Gbd,
Gga,
Gbh,
Gwa,
Ggb,
Gba,
Gpd,
Gmf,
Ghd,
}
impl GasProfile {
pub const ALL: [Self; 15] = [
Self::Hef,
Self::Hmf,
Self::Hko,
Self::Gko,
Self::Gha,
Self::Gmk,
Self::Gbd,
Self::Gga,
Self::Gbh,
Self::Gwa,
Self::Ggb,
Self::Gba,
Self::Gpd,
Self::Gmf,
Self::Ghd,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Hef => "HEF",
Self::Hmf => "HMF",
Self::Hko => "HKO",
Self::Gko => "GKO",
Self::Gha => "GHA",
Self::Gmk => "GMK",
Self::Gbd => "GBD",
Self::Gga => "GGA",
Self::Gbh => "GBH",
Self::Gwa => "GWA",
Self::Ggb => "GGB",
Self::Gba => "GBA",
Self::Gpd => "GPD",
Self::Gmf => "GMF",
Self::Ghd => "GHD",
}
}
#[must_use]
pub const fn is_household(self) -> bool {
matches!(self, Self::Hef | Self::Hmf | Self::Hko)
}
}
crate::ids::codes::string_codes! {
GasProfile, aliases = [("EF", Self::Hef), ("MF", Self::Hmf)];
}
#[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 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,
};
fn h_f64(&self, theta_c: f64) -> Option<f64> {
if theta_c >= self.theta0 {
return None;
}
let ratio = self.b / (theta_c - self.theta0);
if !(ratio > 0.0 && ratio.is_finite()) {
return None;
}
let sigmoid = self.a / (1.0 + ratio.powf(self.c)) + self.d;
let linear = (self.m_h * theta_c + self.b_h).max(self.m_w * theta_c + self.b_w);
let h = sigmoid + linear;
h.is_finite().then_some(h)
}
#[must_use]
pub fn h_value(&self, theta_c: Decimal) -> Option<Decimal> {
use rust_decimal::prelude::ToPrimitive as _;
let h = self.h_f64(theta_c.to_f64()?)?;
if h < 0.0 {
return None;
}
Decimal::try_from(h).ok()
}
}
pub const ALLOCATION_TEMPERATURE_WEIGHTS: [Decimal; 4] =
[dec!(0.5333), dec!(0.2667), dec!(0.1333), dec!(0.0667)];
#[must_use]
pub fn allocation_temperature(
t_d: Decimal,
t_d1: Decimal,
t_d2: Decimal,
t_d3: Decimal,
) -> Option<Decimal> {
[t_d, t_d1, t_d2, t_d3]
.into_iter()
.zip(ALLOCATION_TEMPERATURE_WEIGHTS)
.try_fold(Decimal::ZERO, |sum, (t, w)| {
sum.checked_add(t.checked_mul(w)?)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WeekdayFactors {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_array"))]
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(|h| Holiday::NATIONWIDE.contains(&h) && h.is_nationwide(date.year())),
};
if is_holiday {
self.factor(Weekday::Sunday)
} else {
self.factor(date.weekday())
}
}
}
#[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
.checked_div(h_times_f_sum)?
.round_dp_with_strategy(KUNDENWERT_DP, KUNDENWERT_STRATEGY),
)
}
#[must_use]
pub fn gas_daily_quantity(
kundenwert: Decimal,
h: Decimal,
weekday_factor: Decimal,
) -> Option<Decimal> {
kundenwert.checked_mul(h)?.checked_mul(weekday_factor)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
fn h(p: &SigLinDe, theta: f64) -> f64 {
p.h_f64(theta).expect("inside the fitted range")
}
fn pure_sigmoid(a: f64, b: f64, c: f64, d: f64) -> SigLinDe {
SigLinDe {
a,
b,
c,
d,
theta0: 40.0,
m_h: 0.0,
b_h: 0.0,
m_w: 0.0,
b_w: 0.0,
}
}
#[test]
fn de_hef34_reproduces_the_published_normalisation() {
let h = h(&SigLinDe::DE_HEF34, 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))
.unwrap()
.round_dp_with_strategy(4, crate::precision::KAUFMAENNISCH),
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 v = h(&p, theta);
assert!(v < last, "h must fall with rising temperature at {theta}");
assert!(v > 0.0, "h must stay positive at {theta}");
last = v;
}
assert!(h(&p, 25.0) < 0.2);
assert!(h(&p, -15.0) > 2.0);
}
#[test]
fn no_temperature_answers_with_a_silent_value() {
let p = SigLinDe::DE_HEF34;
assert!(p.h_value(dec!(39.9)).is_some());
for theta in [dec!(40), dec!(45), dec!(500)] {
assert_eq!(p.h_value(theta), None, "{theta}");
}
let negative = SigLinDe {
b_h: -5.0,
b_w: -5.0,
..SigLinDe::DE_HEF34
};
assert_eq!(negative.h_value(dec!(30)), None);
}
#[test]
fn a_pure_sigmoid_has_no_linear_share() {
let tum = pure_sigmoid(3.055_384_2, -36.965_006_5, 7.225_694_7, 0.044_841_6);
assert!((h(&tum, 35.0) - tum.d).abs() < 1e-3);
assert!((h(&tum, -35.0) - (tum.a + tum.d)).abs() < 0.05);
}
#[test]
fn gas_profile_codes_round_trip_and_accept_the_old_aliases() {
for p in GasProfile::ALL {
assert_eq!(p.as_str().parse::<GasProfile>().ok(), Some(p));
}
assert_eq!("EF".parse::<GasProfile>().ok(), Some(GasProfile::Hef));
assert_eq!(" ghd ".parse::<GasProfile>().ok(), Some(GasProfile::Ghd));
assert_eq!(GasProfile::Hef.to_string(), "HEF");
assert!("H0".parse::<GasProfile>().is_err(), "an electricity code");
assert!(GasProfile::Hko.is_household());
assert!(!GasProfile::Ghd.is_household());
}
#[test]
fn the_allocation_temperature_reproduces_the_leitfaden_example() {
use crate::precision::{
ALLOCATION_TEMPERATURE_WEIGHT_DP as DP, ALLOCATION_TEMPERATURE_WEIGHT_STRATEGY as S,
};
let tail: Vec<Decimal> = [4u32, 2, 1]
.iter()
.map(|n| (Decimal::from(*n) / Decimal::from(15u32)).round_dp_with_strategy(DP, S))
.collect();
let t1 = Decimal::ONE - tail.iter().copied().sum::<Decimal>();
assert_eq!(
[t1, tail[0], tail[1], tail[2]],
ALLOCATION_TEMPERATURE_WEIGHTS
);
let theta = allocation_temperature(dec!(-2.0), dec!(0.5), dec!(3.4), dec!(3.6)).unwrap();
assert_eq!(theta, dec!(-0.23991));
assert_eq!(theta.round_dp_with_strategy(4, S), dec!(-0.2399));
assert_eq!(
allocation_temperature(dec!(5), dec!(5), dec!(5), dec!(5)),
Some(dec!(5))
);
}
#[test]
fn h_values_and_tagesmengen_reproduce_the_leitfaden() {
let mathematisch =
|d: Decimal, dp| d.round_dp_with_strategy(dp, crate::precision::MATHEMATISCH);
let d14 = pure_sigmoid(3.185_019_1, -37.412_415_5, 6.172_317_9, 0.076_109_6);
let ok4 = SigLinDe {
a: 1.425_668_4,
b: -36.659_050_4,
c: 7.608_322_6,
d: 0.037_111_6,
theta0: 40.0,
m_h: -0.080_935_9,
b_h: 1.236_452_7,
m_w: -0.000_762_8,
b_w: 0.100_297_9,
};
let h_d14 = d14.h_value(dec!(-0.2)).unwrap();
let h_4ok = ok4.h_value(dec!(-0.2)).unwrap();
assert_eq!(mathematisch(h_d14, 5), dec!(2.01613));
assert_eq!(mathematisch(h_4ok, 5), dec!(2.24285));
let q1 = gas_daily_quantity(dec!(50), h_d14, dec!(1.0000)).unwrap();
let q2 = gas_daily_quantity(dec!(400), h_4ok, dec!(1.0523)).unwrap();
assert_eq!(mathematisch(q1, 4), dec!(100.8067));
assert_eq!(mathematisch(q2, 4), dec!(944.0611));
}
#[test]
fn kundenwerte_reproduce_the_leitfaden() {
assert_eq!(
kundenwert(dec!(23185), dec!(329.5810012)),
Some(dec!(70.3469))
);
assert_eq!(kundenwert(dec!(958), dec!(379.3563091)), Some(dec!(2.5253)));
assert_eq!(
kundenwert(dec!(223185), dec!(397.72740)),
Some(dec!(561.1507))
);
assert_eq!(kundenwert(dec!(1.00005), Decimal::ONE), Some(dec!(1.0000)));
assert_eq!(kundenwert(dec!(1.00015), Decimal::ONE), Some(dec!(1.0002)));
}
#[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).unwrap()).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).unwrap(), Decimal::ONE).unwrap())
.sum();
let error = (reallocated - measured).abs();
assert!(
error < dec!(0.01),
"round trip must close to within rounding: {reallocated} vs {measured}"
);
}
}