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 = "CUSTOM"))]
Custom,
}
impl LoadProfile {
#[must_use]
pub 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::Custom => "CUSTOM",
}
}
pub const ALL: [Self; 32] = [
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::Custom,
];
pub const CODES: &'static [&'static str] = &[
"H0", "G0", "G1", "G2", "G3", "G4", "G5", "G6", "L0", "L1", "L2", "P0", "H25", "G25",
"L25", "P25", "S25", "HEF", "HMF", "HKO", "GKO", "GHA", "GMK", "GBD", "GGA", "GBH", "GWA",
"GGB", "GBA", "GPD", "GMF", "CUSTOM",
];
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"H0" => Some(Self::H0),
"G0" => Some(Self::G0),
"G1" => Some(Self::G1),
"G2" => Some(Self::G2),
"G3" => Some(Self::G3),
"G4" => Some(Self::G4),
"G5" => Some(Self::G5),
"G6" => Some(Self::G6),
"L0" => Some(Self::L0),
"L1" => Some(Self::L1),
"L2" => Some(Self::L2),
"P0" => Some(Self::P0),
"H25" => Some(Self::H25),
"G25" => Some(Self::G25),
"L25" => Some(Self::L25),
"P25" => Some(Self::P25),
"S25" => Some(Self::S25),
"HEF" => Some(Self::GasHEF),
"HMF" => Some(Self::GasHMF),
"HKO" => Some(Self::GasHKO),
"GKO" => Some(Self::GasGKO),
"GHA" => Some(Self::GasGHA),
"GMK" => Some(Self::GasGMK),
"GBD" => Some(Self::GasGBD),
"GGA" => Some(Self::GasGGA),
"GBH" => Some(Self::GasGBH),
"GWA" => Some(Self::GasGWA),
"GGB" => Some(Self::GasGGB),
"GBA" => Some(Self::GasGBA),
"GPD" => Some(Self::GasGPD),
"GMF" => Some(Self::GasGMF),
"EF" => Some(Self::GasHEF),
"MF" => Some(Self::GasHMF),
"CUSTOM" => Some(Self::Custom),
_ => None,
}
}
#[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
)
}
#[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
)
}
}
impl std::fmt::Display for LoadProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for LoadProfile {
type Err = crate::error::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
LoadProfile::parse(s)
.ok_or_else(|| crate::error::ParseError::one_of("LoadProfile", s, Self::CODES))
}
}
#[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,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DynamicSlpProfile {
pub profile: Option<LoadProfile>,
pub values: std::collections::BTreeMap<(u8, SlpDayType), Vec<Decimal>>,
pub dynamization: Option<Dynamization>,
}
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",
];
for p in LoadProfile::ALL {
assert_eq!(p.is_gas(), gas_codes.contains(&p.as_str()), "{p}");
}
assert!(LoadProfile::GasHEF.is_gas());
assert!(LoadProfile::GasGKO.is_gas());
assert!(!LoadProfile::H0.is_gas());
}
#[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"), None);
}
#[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");
}
}