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,
GasEF,
GasMF,
GasGHD,
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::GasEF => "EF",
Self::GasMF => "MF",
Self::GasGHD => "GHD",
Self::Custom => "CUSTOM",
}
}
pub const ALL: [Self; 21] = [
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::GasEF,
Self::GasMF,
Self::GasGHD,
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", "EF", "MF", "GHD", "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),
"EF" => Some(Self::GasEF),
"MF" => Some(Self::GasMF),
"GHD" => Some(Self::GasGHD),
"CUSTOM" => Some(Self::Custom),
_ => None,
}
}
#[must_use]
pub fn is_residential(self) -> bool {
matches!(
self,
Self::H0 | Self::H25 | Self::P25 | Self::S25 | Self::GasEF | Self::GasMF
)
}
#[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::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::GasEF | Self::GasMF | Self::GasGHD)
}
}
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::GasEF,
LoadProfile::GasMF,
LoadProfile::GasGHD,
];
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::GasEF.is_residential());
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() {
assert!(LoadProfile::GasEF.is_gas());
assert!(LoadProfile::GasMF.is_gas());
assert!(LoadProfile::GasGHD.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("ef"), Some(LoadProfile::GasEF));
}
#[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");
}
}