use rust_decimal::Decimal;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::MeterInterval;
use crate::tariff_window::TariffWindow;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AggregationConfig {
pub include_spitzenleistung: bool,
pub include_ht_nt: bool,
pub ht_window: TariffWindow,
}
impl AggregationConfig {
#[must_use]
pub const fn rlm_strom() -> Self {
Self {
include_spitzenleistung: true,
include_ht_nt: false,
ht_window: TariffWindow::BDEW_STANDARD,
}
}
#[must_use]
pub const fn slp_strom() -> Self {
Self {
include_spitzenleistung: false,
include_ht_nt: false,
ht_window: TariffWindow::BDEW_STANDARD,
}
}
#[must_use]
pub const fn rlm_zweitarif() -> Self {
Self {
include_spitzenleistung: true,
include_ht_nt: true,
ht_window: TariffWindow::BDEW_STANDARD,
}
}
#[must_use]
pub const fn gas() -> Self {
Self {
include_spitzenleistung: false,
include_ht_nt: false,
ht_window: TariffWindow::BDEW_STANDARD,
}
}
#[must_use]
pub const fn with_ht_window(mut self, window: TariffWindow) -> Self {
self.ht_window = window;
self
}
}
impl Default for AggregationConfig {
fn default() -> Self {
Self::rlm_strom()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct HtNtSplit {
pub ht_kwh: Decimal,
pub nt_kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BillingPeriod {
pub arbeitsmenge_kwh: Decimal,
pub spitzenleistung_kw: Option<Decimal>,
pub ht_nt: Option<HtNtSplit>,
pub interval_count: usize,
pub coverage_pct: f64,
}
#[must_use]
pub fn aggregate(intervals: &[MeterInterval], config: &AggregationConfig) -> BillingPeriod {
if intervals.is_empty() {
return BillingPeriod {
arbeitsmenge_kwh: Decimal::ZERO,
spitzenleistung_kw: None,
ht_nt: None,
interval_count: 0,
coverage_pct: 0.0,
};
}
let mut sorted = intervals.to_vec();
sorted.sort_by_key(|iv| iv.from);
let billable: Vec<&MeterInterval> = sorted
.iter()
.filter(|iv| iv.quality.is_billable())
.collect();
let arbeitsmenge_kwh: Decimal = billable.iter().map(|iv| iv.value_kwh).sum();
let spitzenleistung_kw = if config.include_spitzenleistung && !billable.is_empty() {
billable
.iter()
.filter_map(|iv| iv.demand_kw())
.reduce(Decimal::max)
} else {
None
};
let ht_nt = if config.include_ht_nt && !billable.is_empty() {
let mut ht = Decimal::ZERO;
let mut nt = Decimal::ZERO;
for iv in &billable {
if config.ht_window.is_ht(iv.from) {
ht += iv.value_kwh;
} else {
nt += iv.value_kwh;
}
}
Some(HtNtSplit {
ht_kwh: ht,
nt_kwh: nt,
})
} else {
None
};
let durations: Vec<i64> = sorted
.iter()
.map(|iv| iv.duration_secs())
.filter(|&d| d > 0)
.collect();
let median_dur = if durations.is_empty() {
900i64
} else {
let mut ds = durations.clone();
ds.sort_unstable();
ds[ds.len() / 2]
};
let period_secs = (sorted.last().unwrap().to - sorted.first().unwrap().from)
.whole_seconds()
.max(1);
let expected_f64 = if median_dur > 0 {
period_secs as f64 / median_dur as f64
} else {
0.0
};
let coverage_pct = if expected_f64 <= 0.0 {
100.0_f64
} else {
((sorted.len() as f64 / expected_f64) * 100.0).min(100.0)
};
BillingPeriod {
arbeitsmenge_kwh,
spitzenleistung_kw,
ht_nt,
interval_count: sorted.len(),
coverage_pct,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::OffsetDateTime;
use time::macros::datetime;
fn iv(from: OffsetDateTime, kwh: Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + time::Duration::minutes(15),
value_kwh: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn spitzenleistung_max_15min() {
let base = datetime!(2026-01-01 0:00 UTC);
let intervals = vec![
iv(base, dec!(2.5)), iv(base + time::Duration::minutes(15), dec!(5.0)), iv(base + time::Duration::minutes(30), dec!(1.0)), ];
let period = aggregate(&intervals, &AggregationConfig::rlm_strom());
assert_eq!(period.spitzenleistung_kw, Some(dec!(20)));
}
#[test]
fn arbeitsmenge_sum_only_billable() {
let base = datetime!(2026-01-01 0:00 UTC);
let mut intervals = vec![
iv(base, dec!(2.0)),
iv(base + time::Duration::minutes(15), dec!(3.0)),
];
intervals[1].quality = QualityFlag::Estimated;
let period = aggregate(&intervals, &AggregationConfig::rlm_strom());
assert_eq!(period.arbeitsmenge_kwh, dec!(5.0));
let mut faulty_intervals = vec![
iv(base, dec!(2.0)),
iv(base + time::Duration::minutes(15), dec!(3.0)),
];
faulty_intervals[1].quality = QualityFlag::Faulty;
let faulty_period = aggregate(&faulty_intervals, &AggregationConfig::rlm_strom());
assert_eq!(faulty_period.arbeitsmenge_kwh, dec!(2.0));
}
#[test]
fn slp_no_spitzenleistung() {
let base = datetime!(2026-01-01 0:00 UTC);
let intervals = vec![iv(base, dec!(24.0))]; let period = aggregate(&intervals, &AggregationConfig::slp_strom());
assert_eq!(period.spitzenleistung_kw, None);
assert_eq!(period.arbeitsmenge_kwh, dec!(24.0));
}
#[test]
fn ht_nt_split() {
let ht_time = datetime!(2026-01-05 7:00 UTC); let nt_time = datetime!(2026-01-05 23:00 UTC); let intervals = vec![
iv(ht_time, dec!(4.0)), iv(nt_time, dec!(1.0)), ];
let period = aggregate(&intervals, &AggregationConfig::rlm_zweitarif());
let ht_nt = period.ht_nt.unwrap();
assert_eq!(ht_nt.ht_kwh, dec!(4.0));
assert_eq!(ht_nt.nt_kwh, dec!(1.0));
}
#[test]
fn empty_intervals() {
let period = aggregate(&[], &AggregationConfig::rlm_strom());
assert_eq!(period.arbeitsmenge_kwh, Decimal::ZERO);
assert_eq!(period.spitzenleistung_kw, None);
assert_eq!(period.interval_count, 0);
}
#[test]
fn spitzenleistung_mess_zv_definition() {
let base = datetime!(2026-06-01 10:00 UTC);
let intervals = vec![
iv(base, dec!(3.5)),
iv(base + time::Duration::minutes(15), dec!(1.0)),
];
let period = aggregate(&intervals, &AggregationConfig::rlm_strom());
assert_eq!(period.spitzenleistung_kw, Some(dec!(14)));
}
#[test]
fn ht_window_uses_german_local_time_not_utc() {
let window = AggregationConfig::rlm_zweitarif().ht_window;
let summer_20h_utc = time::OffsetDateTime::new_utc(
time::macros::date!(2026 - 06 - 01),
time::Time::from_hms(20, 1, 0).unwrap(),
);
assert!(
!window.is_ht(summer_20h_utc),
"20:01 UTC in summer = 22:01 CEST → must be NT, not HT"
);
let summer_10h_utc = time::OffsetDateTime::new_utc(
time::macros::date!(2026 - 06 - 01),
time::Time::from_hms(10, 0, 0).unwrap(),
);
assert!(
window.is_ht(summer_10h_utc),
"10:00 UTC in summer = 12:00 CEST → must be HT"
);
let winter_21h_utc = time::OffsetDateTime::new_utc(
time::macros::date!(2026 - 01 - 15),
time::Time::from_hms(21, 0, 0).unwrap(),
);
assert!(
!window.is_ht(winter_21h_utc),
"21:00 UTC in winter = 22:00 CET → end boundary, must be NT"
);
let winter_20_59_utc = time::OffsetDateTime::new_utc(
time::macros::date!(2026 - 01 - 15),
time::Time::from_hms(20, 59, 0).unwrap(),
);
assert!(
window.is_ht(winter_20_59_utc),
"20:59 UTC in winter = 21:59 CET → must be HT"
);
}
#[test]
fn ht_window_reads_the_weekday_in_berlin_too() {
let window = AggregationConfig::rlm_zweitarif().ht_window;
let monday_early = datetime!(2026-01-04 23:30 UTC);
assert_eq!(
monday_early.weekday(),
time::Weekday::Sunday,
"UTC says Sunday"
);
assert_eq!(
crate::calendar::to_berlin(monday_early).weekday(),
time::Weekday::Monday,
"Berlin says Monday"
);
assert!(!window.is_ht(monday_early));
assert!(!window.is_ht(datetime!(2026-01-03 23:30 UTC)));
let all_days = TariffWindow {
hour_from: 0,
hour_to: 24,
days: crate::tariff_window::TariffWindowDays::WeekdaysOnly,
};
let saturday_early = datetime!(2026-01-02 23:30 UTC);
assert_eq!(saturday_early.weekday(), time::Weekday::Friday);
assert!(
!all_days.is_ht(saturday_early),
"Saturday 00:30 Berlin is not a weekday, even though UTC still says Friday"
);
}
#[test]
fn ht_nt_split_uses_berlin_weekday() {
let intervals = vec![
iv(datetime!(2026-01-02 10:00 UTC), dec!(3.0)),
iv(datetime!(2026-01-02 22:00 UTC), dec!(1.0)),
iv(datetime!(2026-01-02 23:30 UTC), dec!(2.0)),
];
let period = aggregate(&intervals, &AggregationConfig::rlm_zweitarif());
let split = period.ht_nt.unwrap();
assert_eq!(split.ht_kwh, dec!(3.0));
assert_eq!(split.nt_kwh, dec!(3.0));
}
}