use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::MeterInterval;
#[derive(Debug, Clone)]
pub struct AggregationConfig {
pub include_spitzenleistung: bool,
pub include_ht_nt: bool,
pub ht_hours: HtHours,
}
impl AggregationConfig {
#[must_use]
pub fn rlm_strom() -> Self {
Self {
include_spitzenleistung: true,
include_ht_nt: false,
ht_hours: HtHours::default(),
}
}
#[must_use]
pub fn slp_strom() -> Self {
Self {
include_spitzenleistung: false,
include_ht_nt: false,
ht_hours: HtHours::default(),
}
}
#[must_use]
pub fn rlm_zweitarif() -> Self {
Self {
include_spitzenleistung: true,
include_ht_nt: true,
ht_hours: HtHours::default(),
}
}
#[must_use]
pub fn gas() -> Self {
Self {
include_spitzenleistung: false,
include_ht_nt: false,
ht_hours: HtHours::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct HtHours {
pub start: u8,
pub end: u8,
pub include_weekends: bool,
}
impl Default for HtHours {
fn default() -> Self {
Self {
start: 6,
end: 22,
include_weekends: false,
}
}
}
impl HtHours {
#[must_use]
pub fn is_ht(&self, ts: OffsetDateTime) -> bool {
use time::Weekday;
let weekday = ts.weekday();
if !self.include_weekends && matches!(weekday, Weekday::Saturday | Weekday::Sunday) {
return false;
}
let h = ts.hour();
h >= self.start && h < self.end
}
}
#[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_hours.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_macros::dec;
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!(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)));
}
}