use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::MeterInterval;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AggregationConfig {
pub include_spitzenleistung: bool,
pub period: Option<(OffsetDateTime, OffsetDateTime)>,
}
impl AggregationConfig {
#[must_use]
pub const fn rlm() -> Self {
Self {
include_spitzenleistung: true,
period: None,
}
}
#[must_use]
pub const fn arbeitsmenge_only() -> Self {
Self {
include_spitzenleistung: false,
period: None,
}
}
#[must_use]
pub const fn over_period(mut self, from: OffsetDateTime, to: OffsetDateTime) -> Self {
self.period = Some((from, to));
self
}
}
impl Default for AggregationConfig {
fn default() -> Self {
Self::rlm()
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct BillingPeriod {
pub arbeitsmenge: Decimal,
pub spitzenleistung_kw: Option<Decimal>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339_option"))]
pub spitzenleistung_at: Option<OffsetDateTime>,
pub billable_count: usize,
pub excluded_count: usize,
pub coverage_pct: f64,
}
#[must_use]
pub fn aggregate(intervals: &[MeterInterval], config: &AggregationConfig) -> BillingPeriod {
let mut arbeitsmenge = Decimal::ZERO;
let mut billable_count = 0usize;
let mut excluded_count = 0usize;
let mut peak: Option<(Decimal, OffsetDateTime)> = None;
let mut covered_secs = 0i64;
let mut earliest: Option<OffsetDateTime> = None;
let mut latest: Option<OffsetDateTime> = None;
for iv in intervals {
if !iv.quality.is_billable() {
excluded_count += 1;
continue;
}
billable_count += 1;
arbeitsmenge += iv.value;
covered_secs += iv.duration_secs().max(0);
earliest = Some(earliest.map_or(iv.from, |e: OffsetDateTime| e.min(iv.from)));
latest = Some(latest.map_or(iv.to, |l: OffsetDateTime| l.max(iv.to)));
if config.include_spitzenleistung
&& let Some(kw) = iv.demand_kw()
&& peak.is_none_or(|(best, at)| kw > best || (kw == best && iv.from < at))
{
peak = Some((kw, iv.from));
}
}
let period_secs = match config.period {
Some((from, to)) => (to - from).whole_seconds(),
None => match (earliest, latest) {
(Some(f), Some(l)) => (l - f).whole_seconds(),
_ => 0,
},
};
let coverage_pct = if period_secs <= 0 {
if covered_secs > 0 { 100.0 } else { 0.0 }
} else {
((covered_secs as f64 / period_secs as f64) * 100.0).clamp(0.0, 100.0)
};
BillingPeriod {
arbeitsmenge,
spitzenleistung_kw: peak.map(|(kw, _)| kw),
spitzenleistung_at: peak.map(|(_, at)| at),
billable_count,
excluded_count,
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,
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());
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());
assert_eq!(period.arbeitsmenge, 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());
assert_eq!(faulty_period.arbeitsmenge, 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::arbeitsmenge_only());
assert_eq!(period.spitzenleistung_kw, None);
assert_eq!(period.arbeitsmenge, dec!(24.0));
}
#[test]
fn empty_intervals() {
let period = aggregate(&[], &AggregationConfig::rlm());
assert_eq!(period.arbeitsmenge, Decimal::ZERO);
assert_eq!(period.spitzenleistung_kw, None);
assert_eq!(period.billable_count, 0);
assert_eq!(period.coverage_pct, 0.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());
assert_eq!(period.spitzenleistung_kw, Some(dec!(14)));
}
}
#[cfg(test)]
mod order_tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::datetime;
#[test]
fn aggregation_is_order_independent() {
let base = datetime!(2026-01-01 0:00 UTC);
let ordered: Vec<MeterInterval> = [dec!(2.5), dec!(5.0), dec!(1.0), dec!(3.25)]
.into_iter()
.enumerate()
.map(|(i, value)| MeterInterval {
from: base + time::Duration::minutes(i as i64 * 15),
to: base + time::Duration::minutes(i as i64 * 15 + 15),
value,
quality: QualityFlag::Measured,
obis_code: None,
})
.collect();
let mut shuffled = ordered.clone();
shuffled.reverse();
let a = aggregate(&ordered, &AggregationConfig::rlm());
let b = aggregate(&shuffled, &AggregationConfig::rlm());
assert_eq!(a, b);
assert_eq!(
a.spitzenleistung_at,
Some(base + time::Duration::minutes(15))
);
}
#[test]
fn a_tied_peak_reports_the_earliest_interval() {
let base = datetime!(2026-01-01 0:00 UTC);
let flat: Vec<MeterInterval> = (0..4)
.map(|i| MeterInterval {
from: base + time::Duration::minutes(i * 15),
to: base + time::Duration::minutes(i * 15 + 15),
value: dec!(5),
quality: QualityFlag::Measured,
obis_code: None,
})
.collect();
let mut reversed = flat.clone();
reversed.reverse();
let forward = aggregate(&flat, &AggregationConfig::rlm());
let backward = aggregate(&reversed, &AggregationConfig::rlm());
assert_eq!(forward, backward);
assert_eq!(
forward.spitzenleistung_at,
Some(base),
"the first quarter-hour the peak was reached in, not the first listed"
);
assert_eq!(forward.spitzenleistung_kw, Some(dec!(20)));
}
}