use std::collections::BTreeMap;
use rust_decimal::{Decimal, dec};
use time::OffsetDateTime;
use crate::precision::{
DYNAMIZATION_DP, DYNAMIZATION_STRATEGY, DYNAMIZED_VALUE_DP, DYNAMIZED_VALUE_STRATEGY,
};
use crate::time::holiday::SlpCalendar;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum LoadProfile {
H0,
G0,
G1,
G2,
G3,
G4,
G5,
G6,
L0,
L1,
L2,
H25,
G25,
L25,
P25,
S25,
}
impl LoadProfile {
pub const ALL: [Self; 16] = [
Self::H0,
Self::G0,
Self::G1,
Self::G2,
Self::G3,
Self::G4,
Self::G5,
Self::G6,
Self::L0,
Self::L1,
Self::L2,
Self::H25,
Self::G25,
Self::L25,
Self::P25,
Self::S25,
];
#[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::H25 => "H25",
Self::G25 => "G25",
Self::L25 => "L25",
Self::P25 => "P25",
Self::S25 => "S25",
}
}
#[must_use]
pub const fn is_residential(self) -> bool {
matches!(self, Self::H0 | Self::H25 | Self::P25 | Self::S25)
}
#[must_use]
pub const fn is_commercial(self) -> bool {
matches!(
self,
Self::G0 | Self::G1 | Self::G2 | Self::G3 | Self::G4 | Self::G5 | Self::G6 | Self::G25
)
}
#[must_use]
pub const fn is_agricultural(self) -> bool {
matches!(self, Self::L0 | Self::L1 | Self::L2 | Self::L25)
}
#[must_use]
pub const fn requires_dynamization(self) -> bool {
matches!(self, Self::H0 | Self::H25 | Self::P25 | Self::S25)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Dynamization {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_array"))]
pub coefficients: [Decimal; 5],
}
impl Dynamization {
pub const BDEW: Self = Self {
coefficients: [
dec!(-0.000000000392),
dec!(0.00000032),
dec!(-0.0000702),
dec!(0.0021),
dec!(1.24),
],
};
#[must_use]
pub fn factor(&self, day_of_year: u16) -> Option<Decimal> {
if !(1..=366).contains(&day_of_year) {
return None;
}
let t = Decimal::from(day_of_year);
let f = self
.coefficients
.iter()
.try_fold(Decimal::ZERO, |acc, c| acc.checked_mul(t)?.checked_add(*c))?;
Some(f.round_dp_with_strategy(DYNAMIZATION_DP, DYNAMIZATION_STRATEGY))
}
#[must_use]
pub fn apply(&self, profile_value: Decimal, day_of_year: u16) -> Option<Decimal> {
Some(
profile_value
.checked_mul(self.factor(day_of_year)?)?
.round_dp_with_strategy(DYNAMIZED_VALUE_DP, DYNAMIZED_VALUE_STRATEGY),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
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::ids::codes::string_codes! {
SlpDayType;
LoadProfile;
}
pub const QUARTERS_PER_DAY_TABLE: usize = 96;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SlpTableError {
#[error("month {0} is not 1..=12")]
Month(u8),
#[error("a day table holds 96 quarter-hour values, got {0}")]
Length(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DynamicSlpProfile {
profile: LoadProfile,
values: BTreeMap<(u8, SlpDayType), Vec<Decimal>>,
dynamization: Option<Dynamization>,
}
impl DynamicSlpProfile {
#[must_use]
pub const fn new(profile: LoadProfile) -> Self {
Self {
profile,
values: BTreeMap::new(),
dynamization: None,
}
}
#[must_use]
pub const fn dynamization(mut self, function: Dynamization) -> Self {
self.dynamization = Some(function);
self
}
pub fn insert(
&mut self,
month: u8,
day_type: SlpDayType,
values: Vec<Decimal>,
) -> Result<(), SlpTableError> {
if !(1..=12).contains(&month) {
return Err(SlpTableError::Month(month));
}
if values.len() != QUARTERS_PER_DAY_TABLE {
return Err(SlpTableError::Length(values.len()));
}
self.values.insert((month, day_type), values);
Ok(())
}
#[must_use]
pub const fn profile(&self) -> LoadProfile {
self.profile
}
#[must_use]
pub fn is_complete(&self) -> bool {
(1u8..=12).all(|m| {
SlpDayType::ALL
.iter()
.all(|dt| self.values.contains_key(&(m, *dt)))
})
}
#[must_use]
pub fn value_at(&self, instant: OffsetDateTime, calendar: &SlpCalendar) -> Option<Decimal> {
let date = crate::DayBoundary::Strom.day_of(instant)?;
let local = crate::time::calendar::to_berlin(instant);
let quarter = usize::from(local.hour()) * 4 + usize::from(local.minute()) / 15;
let raw = self
.values
.get(&(u8::from(date.month()), calendar.day_type(date)))?
.get(quarter)
.copied()?;
if self.profile.requires_dynamization() {
self.dynamization?.apply(raw, date.ordinal())
} else {
Some(raw)
}
}
}
#[cfg(feature = "serde")]
#[derive(Serialize, Deserialize)]
struct RawProfile {
profile: LoadProfile,
dynamization: Option<Dynamization>,
days: Vec<RawDay>,
}
#[cfg(feature = "serde")]
#[derive(Serialize, Deserialize)]
struct RawDay {
month: u8,
day_type: SlpDayType,
#[serde(with = "crate::wire::decimal_vec")]
values: Vec<Decimal>,
}
#[cfg(feature = "serde")]
impl Serialize for DynamicSlpProfile {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
RawProfile {
profile: self.profile,
dynamization: self.dynamization,
days: self
.values
.iter()
.map(|(&(month, day_type), values)| RawDay {
month,
day_type,
values: values.clone(),
})
.collect(),
}
.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for DynamicSlpProfile {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = RawProfile::deserialize(deserializer)?;
let mut table = Self::new(raw.profile);
table.dynamization = raw.dynamization;
for day in raw.days {
table
.insert(day.month, day.day_type, day.values)
.map_err(serde::de::Error::custom)?;
}
Ok(table)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::holiday::Bundesland;
use time::macros::datetime;
fn flat(
profile: LoadProfile,
month: u8,
day_type: SlpDayType,
v: Decimal,
) -> DynamicSlpProfile {
let mut t = DynamicSlpProfile::new(profile).dynamization(Dynamization::BDEW);
t.insert(month, day_type, vec![v; 96]).unwrap();
t
}
#[test]
fn every_profile_round_trips_its_code() {
for p in LoadProfile::ALL {
assert_eq!(p.as_str().parse::<LoadProfile>().ok(), Some(p));
}
assert_eq!(" h0 ".parse::<LoadProfile>().ok(), Some(LoadProfile::H0));
}
#[test]
fn gas_codes_are_not_electricity_profiles() {
for gas in ["HEF", "HMF", "GKO", "GHD", "EF", "CUSTOM"] {
assert!(gas.parse::<LoadProfile>().is_err(), "{gas}");
}
}
#[test]
fn classification() {
assert!(LoadProfile::H0.is_residential());
assert!(LoadProfile::G25.is_commercial());
assert!(LoadProfile::L25.is_agricultural());
assert!(!LoadProfile::H0.is_commercial());
for (p, dynamized) in [
(LoadProfile::H25, true),
(LoadProfile::G25, false),
(LoadProfile::L25, false),
(LoadProfile::P25, true),
(LoadProfile::S25, true),
(LoadProfile::H0, true),
(LoadProfile::G0, false),
] {
assert_eq!(p.requires_dynamization(), dynamized, "{p}");
}
}
#[test]
fn dynamization_reproduces_the_published_check_values() {
let d = Dynamization::BDEW;
assert_eq!(d.factor(1), Some(dec!(1.2420)));
assert_eq!(d.factor(2), Some(dec!(1.2439)));
assert_eq!(d.factor(202), Some(dec!(0.7847)));
assert_eq!(d.factor(365), Some(dec!(1.2572)));
assert_eq!(d.factor(366), Some(dec!(1.2597)));
assert_eq!(d.apply(dec!(87.5), 1), Some(dec!(108.675)));
assert_eq!(d.factor(0), None);
assert_eq!(d.factor(367), None);
}
#[test]
fn a_day_table_is_validated() {
let mut t = DynamicSlpProfile::new(LoadProfile::G25);
assert_eq!(
t.insert(13, SlpDayType::Werktag, vec![dec!(1); 96]),
Err(SlpTableError::Month(13))
);
assert_eq!(
t.insert(1, SlpDayType::Werktag, vec![dec!(1); 95]),
Err(SlpTableError::Length(95))
);
assert!(!t.is_complete());
}
#[test]
fn the_lookup_reads_the_berlin_wall_clock_quarter_hour() {
let mut g25 = DynamicSlpProfile::new(LoadProfile::G25);
let mut rows: Vec<Decimal> = (0..96).map(Decimal::from).collect();
g25.insert(1, SlpDayType::Werktag, rows.clone()).unwrap();
rows.reverse();
g25.insert(7, SlpDayType::Werktag, rows).unwrap();
let cal = SlpCalendar::new(Bundesland::Be);
assert_eq!(
g25.value_at(datetime!(2026-01-05 23:00 UTC), &cal),
Some(dec!(0))
);
assert_eq!(
g25.value_at(datetime!(2026-01-06 11:14 UTC), &cal),
Some(dec!(48))
);
assert_eq!(
g25.value_at(datetime!(2026-07-07 10:00 UTC), &cal),
Some(dec!(47))
);
}
#[test]
fn dst_days_follow_the_wall_clock() {
let mut g25 = DynamicSlpProfile::new(LoadProfile::G25);
let rows: Vec<Decimal> = (0..96).map(Decimal::from).collect();
g25.insert(10, SlpDayType::SonnFeiertag, rows.clone())
.unwrap();
g25.insert(3, SlpDayType::SonnFeiertag, rows).unwrap();
let cal = SlpCalendar::new(Bundesland::Be);
let first = g25.value_at(datetime!(2026-10-25 0:00 UTC), &cal);
let second = g25.value_at(datetime!(2026-10-25 1:00 UTC), &cal);
assert_eq!(first, Some(dec!(8)));
assert_eq!(first, second);
assert_eq!(
g25.value_at(datetime!(2026-03-29 0:45 UTC), &cal),
Some(dec!(7))
);
assert_eq!(
g25.value_at(datetime!(2026-03-29 1:00 UTC), &cal),
Some(dec!(12))
);
}
#[test]
fn the_lookup_uses_the_bundesland_calendar() {
let h25 = flat(LoadProfile::H25, 6, SlpDayType::SonnFeiertag, dec!(100));
let noon = datetime!(2026-06-04 10:00 UTC);
assert!(
h25.value_at(noon, &SlpCalendar::new(Bundesland::By))
.is_some()
);
assert!(
h25.value_at(noon, &SlpCalendar::new(Bundesland::Be))
.is_none()
);
}
#[test]
fn a_profile_needing_dynamization_refuses_without_a_function() {
let mut h25 = DynamicSlpProfile::new(LoadProfile::H25);
h25.insert(1, SlpDayType::Werktag, vec![dec!(100); 96])
.unwrap();
let cal = SlpCalendar::new(Bundesland::Be);
let at = datetime!(2026-01-15 10:00 UTC);
assert_eq!(h25.value_at(at, &cal), None);
let h25 = h25.dynamization(Dynamization::BDEW);
assert_eq!(h25.value_at(at, &cal), Some(dec!(125.680)));
let g25 = flat(LoadProfile::G25, 1, SlpDayType::Werktag, dec!(100));
assert_eq!(g25.value_at(at, &cal), Some(dec!(100)));
}
#[cfg(feature = "serde")]
#[test]
fn a_profile_table_round_trips_through_json() {
let table = flat(LoadProfile::H25, 6, SlpDayType::Werktag, dec!(1.5));
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("\"day_type\":\"WERKTAG\""), "{json}");
let back: DynamicSlpProfile = serde_json::from_str(&json).unwrap();
assert_eq!(back, table);
let short = json.replacen("\"1.5\",", "", 1);
assert!(serde_json::from_str::<DynamicSlpProfile>(&short).is_err());
}
}