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))]
pub enum LoadProfile {
H0,
G0,
G1,
G2,
G3,
G4,
G5,
G6,
L0,
L1,
L2,
P0,
H25,
G25,
L25,
P25,
S25,
#[cfg_attr(feature = "serde", serde(rename = "HEF"))]
GasHEF,
#[cfg_attr(feature = "serde", serde(rename = "HMF"))]
GasHMF,
#[cfg_attr(feature = "serde", serde(rename = "HKO"))]
GasHKO,
#[cfg_attr(feature = "serde", serde(rename = "GKO"))]
GasGKO,
#[cfg_attr(feature = "serde", serde(rename = "GHA"))]
GasGHA,
#[cfg_attr(feature = "serde", serde(rename = "GMK"))]
GasGMK,
#[cfg_attr(feature = "serde", serde(rename = "GBD"))]
GasGBD,
#[cfg_attr(feature = "serde", serde(rename = "GGA"))]
GasGGA,
#[cfg_attr(feature = "serde", serde(rename = "GBH"))]
GasGBH,
#[cfg_attr(feature = "serde", serde(rename = "GWA"))]
GasGWA,
#[cfg_attr(feature = "serde", serde(rename = "GGB"))]
GasGGB,
#[cfg_attr(feature = "serde", serde(rename = "GBA"))]
GasGBA,
#[cfg_attr(feature = "serde", serde(rename = "GPD"))]
GasGPD,
#[cfg_attr(feature = "serde", serde(rename = "GMF"))]
GasGMF,
#[cfg_attr(feature = "serde", serde(rename = "GHD"))]
GasGHD,
#[cfg_attr(feature = "serde", serde(rename = "CUSTOM"))]
Custom,
}
impl LoadProfile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::H0 => "H0",
Self::G0 => "G0",
Self::G1 => "G1",
Self::G2 => "G2",
Self::G3 => "G3",
Self::G4 => "G4",
Self::G5 => "G5",
Self::G6 => "G6",
Self::L0 => "L0",
Self::L1 => "L1",
Self::L2 => "L2",
Self::P0 => "P0",
Self::H25 => "H25",
Self::G25 => "G25",
Self::L25 => "L25",
Self::P25 => "P25",
Self::S25 => "S25",
Self::GasHEF => "HEF",
Self::GasHMF => "HMF",
Self::GasHKO => "HKO",
Self::GasGKO => "GKO",
Self::GasGHA => "GHA",
Self::GasGMK => "GMK",
Self::GasGBD => "GBD",
Self::GasGGA => "GGA",
Self::GasGBH => "GBH",
Self::GasGWA => "GWA",
Self::GasGGB => "GGB",
Self::GasGBA => "GBA",
Self::GasGPD => "GPD",
Self::GasGMF => "GMF",
Self::GasGHD => "GHD",
Self::Custom => "CUSTOM",
}
}
pub const ALL: [Self; 33] = [
Self::H0,
Self::G0,
Self::G1,
Self::G2,
Self::G3,
Self::G4,
Self::G5,
Self::G6,
Self::L0,
Self::L1,
Self::L2,
Self::P0,
Self::H25,
Self::G25,
Self::L25,
Self::P25,
Self::S25,
Self::GasHEF,
Self::GasHMF,
Self::GasHKO,
Self::GasGKO,
Self::GasGHA,
Self::GasGMK,
Self::GasGBD,
Self::GasGGA,
Self::GasGBH,
Self::GasGWA,
Self::GasGGB,
Self::GasGBA,
Self::GasGPD,
Self::GasGMF,
Self::GasGHD,
Self::Custom,
];
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
s.parse().ok()
}
#[must_use]
pub fn is_residential(self) -> bool {
matches!(
self,
Self::H0
| Self::H25
| Self::P25
| Self::S25
| Self::GasHEF
| Self::GasHMF
| Self::GasHKO
)
}
#[must_use]
pub fn requires_dynamization(self) -> bool {
matches!(self, Self::H0 | Self::H25 | Self::P25 | Self::S25)
}
#[must_use]
pub fn is_commercial(self) -> bool {
matches!(
self,
Self::G0
| Self::G1
| Self::G2
| Self::G3
| Self::G4
| Self::G5
| Self::G6
| Self::G25
| Self::GasGKO
| Self::GasGHA
| Self::GasGMK
| Self::GasGBD
| Self::GasGGA
| Self::GasGBH
| Self::GasGWA
| Self::GasGGB
| Self::GasGBA
| Self::GasGPD
| Self::GasGMF
| Self::GasGHD
)
}
#[must_use]
pub fn is_agricultural(self) -> bool {
matches!(self, Self::L0 | Self::L1 | Self::L2)
}
#[must_use]
pub fn is_gas(self) -> bool {
matches!(
self,
Self::GasHEF
| Self::GasHMF
| Self::GasHKO
| Self::GasGKO
| Self::GasGHA
| Self::GasGMK
| Self::GasGBD
| Self::GasGGA
| Self::GasGBH
| Self::GasGWA
| Self::GasGGB
| Self::GasGBA
| Self::GasGPD
| Self::GasGMF
| Self::GasGHD
)
}
#[must_use]
pub fn is_gas_aggregate(self) -> bool {
matches!(self, Self::GasGHD)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Dynamization {
pub coefficients: [f64; 5],
}
impl Dynamization {
#[must_use]
pub fn vdew_1999() -> Self {
Self {
coefficients: [-3.92e-10, 3.2e-7, -7.02e-5, 2.1e-3, 1.24],
}
}
#[must_use]
pub fn factor(&self, day_of_year: u16) -> Decimal {
let t = f64::from(day_of_year);
let [a, b, c, d, e] = self.coefficients;
let f = a * t.powi(4) + b * t.powi(3) + c * t.powi(2) + d * t + e;
Decimal::try_from(f).unwrap_or(Decimal::ONE).round_dp(4)
}
#[must_use]
pub fn apply(&self, profile_value: Decimal, day_of_year: u16) -> Decimal {
(profile_value * self.factor(day_of_year)).round_dp(3)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SlpDayType {
Werktag,
Samstag,
SonnFeiertag,
}
impl SlpDayType {
pub const ALL: [Self; 3] = [Self::Werktag, Self::Samstag, Self::SonnFeiertag];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Werktag => "WERKTAG",
Self::Samstag => "SAMSTAG",
Self::SonnFeiertag => "SONN_FEIERTAG",
}
}
}
crate::codes::string_codes! {
SlpDayType;
LoadProfile, aliases = [("EF", Self::GasHEF), ("MF", Self::GasHMF)];
}
pub type SlpValueTable = std::collections::BTreeMap<(u8, SlpDayType), Vec<Decimal>>;
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DynamicSlpProfile {
pub profile: Option<LoadProfile>,
#[cfg_attr(feature = "serde", serde(with = "profile_table"))]
pub values: SlpValueTable,
pub dynamization: Option<Dynamization>,
}
#[cfg(feature = "serde")]
mod profile_table {
use super::{Decimal, SlpDayType, SlpValueTable};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct DayTable {
month: u8,
day_type: SlpDayType,
values: Vec<Decimal>,
}
pub(super) fn serialize<S: Serializer>(
table: &SlpValueTable,
serializer: S,
) -> Result<S::Ok, S::Error> {
let rows: Vec<DayTable> = table
.iter()
.map(|(&(month, day_type), values)| DayTable {
month,
day_type,
values: values.clone(),
})
.collect();
rows.serialize(serializer)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<SlpValueTable, D::Error> {
Ok(Vec::<DayTable>::deserialize(deserializer)?
.into_iter()
.map(|row| ((row.month, row.day_type), row.values))
.collect())
}
}
impl DynamicSlpProfile {
#[must_use]
pub fn value_at(
&self,
month: u8,
day_type: SlpDayType,
quarter: usize,
day_of_year: u16,
) -> Option<Decimal> {
let raw = self
.values
.get(&(month, day_type))
.and_then(|day| day.get(quarter))
.copied()?;
if self.profile.is_none_or(LoadProfile::requires_dynamization) {
return Some(self.dynamization?.apply(raw, day_of_year));
}
Some(raw)
}
#[must_use]
pub fn value_on(
&self,
date: time::Date,
land: crate::holiday::Bundesland,
quarter: usize,
) -> Option<Decimal> {
self.value_at(
u8::from(date.month()),
crate::holiday::slp_day_type(date, land),
quarter,
date.ordinal(),
)
}
#[must_use]
pub fn is_complete(&self) -> bool {
let day_types = [
SlpDayType::Werktag,
SlpDayType::Samstag,
SlpDayType::SonnFeiertag,
];
(1u8..=12).all(|m| {
day_types
.iter()
.all(|dt| self.values.get(&(m, *dt)).is_some_and(|v| v.len() == 96))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_all_profiles() {
let profiles = [
LoadProfile::H0,
LoadProfile::G0,
LoadProfile::G1,
LoadProfile::G2,
LoadProfile::G3,
LoadProfile::G4,
LoadProfile::G5,
LoadProfile::G6,
LoadProfile::L0,
LoadProfile::L1,
LoadProfile::L2,
LoadProfile::P0,
LoadProfile::GasHEF,
LoadProfile::GasHMF,
LoadProfile::GasHKO,
LoadProfile::GasGKO,
LoadProfile::GasGGA,
LoadProfile::GasGMF,
];
for p in &profiles {
let s = p.as_str();
let parsed = LoadProfile::parse(s).expect("should round-trip");
assert_eq!(*p, parsed, "round-trip failed for {s}");
}
}
#[test]
fn residential_classification() {
assert!(LoadProfile::H0.is_residential());
assert!(LoadProfile::GasHEF.is_residential());
assert!(LoadProfile::GasHKO.is_residential());
assert!(
!LoadProfile::GasGMF.is_residential(),
"GMF is haushaltsähnlich, not a household"
);
assert!(!LoadProfile::G0.is_residential());
}
#[test]
fn commercial_classification() {
for p in [
LoadProfile::G0,
LoadProfile::G1,
LoadProfile::G2,
LoadProfile::G3,
LoadProfile::G4,
LoadProfile::G5,
LoadProfile::G6,
] {
assert!(p.is_commercial(), "{p} should be commercial");
}
assert!(!LoadProfile::H0.is_commercial());
}
#[test]
fn gas_classification() {
let gas_codes = [
"HEF", "HMF", "HKO", "GKO", "GHA", "GMK", "GBD", "GGA", "GBH", "GWA", "GGB", "GBA",
"GPD", "GMF", "GHD",
];
for p in LoadProfile::ALL {
assert_eq!(p.is_gas(), gas_codes.contains(&p.as_str()), "{p}");
}
assert_eq!(gas_codes.len(), 15, "the Leitfaden publishes fifteen");
assert!(LoadProfile::GasHEF.is_gas());
assert!(LoadProfile::GasGKO.is_gas());
assert!(!LoadProfile::H0.is_gas());
assert!(LoadProfile::GasGHD.is_gas());
assert!(LoadProfile::GasGHD.is_commercial());
assert!(LoadProfile::GasGHD.is_gas_aggregate());
assert!(!LoadProfile::GasGKO.is_gas_aggregate());
}
#[test]
fn case_insensitive_parse() {
assert_eq!(LoadProfile::parse("h0"), Some(LoadProfile::H0));
assert_eq!(LoadProfile::parse("H0"), Some(LoadProfile::H0));
assert_eq!(LoadProfile::parse("hef"), Some(LoadProfile::GasHEF));
assert_eq!(LoadProfile::parse("EF"), Some(LoadProfile::GasHEF));
assert_eq!(LoadProfile::parse("MF"), Some(LoadProfile::GasHMF));
assert_eq!(LoadProfile::parse("GHD"), Some(LoadProfile::GasGHD));
assert_eq!(LoadProfile::parse(" ghd "), Some(LoadProfile::GasGHD));
assert_eq!(LoadProfile::GasGHD.as_str(), "GHD");
}
#[test]
fn unknown_profile_returns_none() {
assert_eq!(LoadProfile::parse("X9"), None);
assert_eq!(LoadProfile::parse(""), None);
}
#[test]
fn profiles_2025_round_trip_and_dynamization_flags() {
for (code, dynamized) in [
("H25", true),
("G25", false),
("L25", false),
("P25", true),
("S25", true),
] {
let p = LoadProfile::parse(code).expect(code);
assert_eq!(p.as_str(), code);
assert_eq!(p.requires_dynamization(), dynamized, "{code}");
}
assert!(LoadProfile::H0.requires_dynamization());
assert!(!LoadProfile::G0.requires_dynamization());
assert!(!LoadProfile::L0.requires_dynamization());
}
#[test]
fn vdew_1999_dynamization_matches_the_published_shape() {
use rust_decimal::dec;
let d = Dynamization::vdew_1999();
let jan = d.factor(15);
let jul = d.factor(196);
assert!(jan > Decimal::ONE, "winter factor {jan} > 1");
assert!(jul < Decimal::ONE, "summer factor {jul} < 1");
assert_eq!(jan, jan.round_dp(4));
let applied = d.apply(dec!(1.23456), 15);
assert_eq!(applied, applied.round_dp(3));
}
#[test]
fn dynamic_profile_lookup_applies_dynamization_only_where_required() {
use rust_decimal::dec;
let mut h25 = DynamicSlpProfile {
profile: Some(LoadProfile::H25),
dynamization: Some(Dynamization::vdew_1999()),
..Default::default()
};
h25.values
.insert((1, SlpDayType::Werktag), vec![dec!(100); 96]);
let v = h25.value_at(1, SlpDayType::Werktag, 0, 15).unwrap();
assert_ne!(v, dec!(100), "H25 must be dynamized");
let mut g25 = DynamicSlpProfile {
profile: Some(LoadProfile::G25),
..Default::default()
};
g25.values
.insert((1, SlpDayType::Werktag), vec![dec!(100); 96]);
assert_eq!(
g25.value_at(1, SlpDayType::Werktag, 0, 15).unwrap(),
dec!(100),
"G25 carries no Dynamisierung, and needs none supplied"
);
assert!(!g25.is_complete(), "one month/day-type is not a full table");
}
#[test]
fn a_profile_needing_dynamization_refuses_without_a_function() {
use rust_decimal::dec;
let mut h25 = DynamicSlpProfile {
profile: Some(LoadProfile::H25),
dynamization: None,
..Default::default()
};
h25.values
.insert((1, SlpDayType::Werktag), vec![dec!(100); 96]);
assert_eq!(h25.value_at(1, SlpDayType::Werktag, 0, 15), None);
h25.dynamization = Some(Dynamization::vdew_1999());
assert!(h25.value_at(1, SlpDayType::Werktag, 0, 15).is_some());
let unknown = DynamicSlpProfile {
profile: None,
values: h25.values.clone(),
dynamization: None,
};
assert_eq!(unknown.value_at(1, SlpDayType::Werktag, 0, 15), None);
}
#[test]
fn the_date_lookup_uses_the_bundesland_calendar() {
use crate::Bundesland;
use rust_decimal::dec;
use time::macros::date;
let mut h25 = DynamicSlpProfile {
profile: Some(LoadProfile::H25),
dynamization: Some(Dynamization::vdew_1999()),
..Default::default()
};
h25.values
.insert((6, SlpDayType::SonnFeiertag), vec![dec!(100); 96]);
let fronleichnam = date!(2026 - 06 - 04);
assert!(h25.value_on(fronleichnam, Bundesland::By, 0).is_some());
assert!(
h25.value_on(fronleichnam, Bundesland::Be, 0).is_none(),
"in Berlin it is a Werktag, and that table is not loaded"
);
h25.values
.insert((1, SlpDayType::SonnFeiertag), vec![dec!(100); 96]);
let new_year = h25.value_on(date!(2026 - 01 - 01), Bundesland::By, 0);
let midsummer = h25.value_on(date!(2026 - 06 - 07), Bundesland::By, 0);
assert!(new_year > midsummer, "winter runs above summer");
}
}
#[cfg(test)]
mod parse_and_wire_tests {
use super::*;
#[test]
fn parsing_is_case_and_whitespace_insensitive() {
for spelling in ["H0", "h0", " H0 ", "\th0\n"] {
assert_eq!(
LoadProfile::parse(spelling),
Some(LoadProfile::H0),
"{spelling:?}"
);
assert_eq!(spelling.parse::<LoadProfile>().unwrap(), LoadProfile::H0);
}
assert_eq!(LoadProfile::parse(" hef "), Some(LoadProfile::GasHEF));
assert!(LoadProfile::parse("X9").is_none(), "not a profile code");
}
#[cfg(feature = "serde")]
#[test]
fn a_profile_table_round_trips_through_json() {
use rust_decimal::dec;
let mut profile = DynamicSlpProfile {
profile: Some(LoadProfile::H25),
dynamization: Some(Dynamization::vdew_1999()),
..Default::default()
};
profile
.values
.insert((6, SlpDayType::Werktag), vec![dec!(1.5); 96]);
profile
.values
.insert((6, SlpDayType::SonnFeiertag), vec![dec!(0.9); 96]);
let json = serde_json::to_string(&profile).expect("a table must serialise");
assert!(json.contains("\"day_type\":\"WERKTAG\""), "{json}");
assert!(json.contains("\"month\":6"), "{json}");
let back: DynamicSlpProfile = serde_json::from_str(&json).expect("...and read back");
assert_eq!(back.values, profile.values);
assert_eq!(back.profile, profile.profile);
assert_eq!(back.dynamization, profile.dynamization);
}
}