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 {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub arbeitsmenge: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
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 uniform_resolution: bool,
pub covered_secs: i64,
pub period_secs: i64,
pub coverage_pct: f64,
}
pub const STROMNEV_AUSSERKRAFT: time::Date =
match time::Date::from_calendar_date(2028, time::Month::December, 31) {
Ok(d) => d,
Err(_) => unreachable!(),
};
pub const BENUTZUNGSDAUER_DP: u32 = 2;
#[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;
let mut length: Option<i64> = None;
let mut uniform_resolution = true;
for iv in intervals {
if !iv.quality.is_billable() {
excluded_count += 1;
continue;
}
billable_count += 1;
arbeitsmenge += iv.value;
let secs = iv.duration_secs();
covered_secs += secs.max(0);
match length {
Some(first) if first != secs => uniform_resolution = false,
Some(_) => {}
None => length = Some(secs),
}
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,
uniform_resolution,
covered_secs,
period_secs,
coverage_pct,
}
}
impl BillingPeriod {
#[must_use]
pub const fn coverage_overcounts(&self) -> bool {
self.period_secs > 0 && self.covered_secs > self.period_secs
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DirectionalEnergy {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub import: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub export: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub undirected: Decimal,
}
impl BillingPeriod {
#[must_use]
pub fn benutzungsdauer_h(&self) -> Option<Decimal> {
let peak = self.spitzenleistung_kw?;
if peak <= Decimal::ZERO {
return None;
}
Some((self.arbeitsmenge / peak).round_dp(BENUTZUNGSDAUER_DP))
}
}
impl DirectionalEnergy {
#[must_use]
pub fn net(&self) -> Decimal {
self.import - self.export
}
#[must_use]
pub fn total(&self) -> Decimal {
self.import + self.export + self.undirected
}
}
#[must_use]
pub fn sum_by_direction(intervals: &[MeterInterval]) -> DirectionalEnergy {
let mut out = DirectionalEnergy::default();
for iv in intervals {
match iv.direction() {
Some(crate::interval::Direction::Import) => out.import += iv.value,
Some(crate::interval::Direction::Export) => out.export += iv.value,
None => out.undirected += iv.value,
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::OffsetDateTime;
use time::macros::datetime;
use time::Duration;
#[test]
fn the_stromnev_basis_has_a_stated_end_date() {
assert_eq!(
STROMNEV_AUSSERKRAFT,
time::macros::date!(2028 - 12 - 31),
"the Jahreshöchstleistung basis, not the quantity, lapses here"
);
}
#[test]
fn a_mixed_resolution_series_is_reported_as_mixed() {
let base = datetime!(2026-06-01 0:00 UTC);
let quarter = MeterInterval {
from: base,
to: base + Duration::minutes(15),
value: dec!(1),
quality: QualityFlag::Measured,
obis_code: None,
};
let hour = MeterInterval {
from: base + Duration::minutes(15),
to: base + Duration::minutes(75),
value: dec!(2),
quality: QualityFlag::Measured,
obis_code: None,
};
let uniform = aggregate(std::slice::from_ref(&quarter), &AggregationConfig::rlm());
assert!(uniform.uniform_resolution);
let mixed = aggregate(&[quarter, hour], &AggregationConfig::rlm());
assert!(!mixed.uniform_resolution);
assert_eq!(mixed.spitzenleistung_kw, Some(dec!(4)));
assert_eq!(mixed.benutzungsdauer_h(), Some(dec!(0.75)));
}
#[test]
fn utilisation_hours_need_a_peak() {
let base = datetime!(2026-06-01 0:00 UTC);
let faulty = MeterInterval {
from: base,
to: base + Duration::minutes(15),
value: dec!(1),
quality: QualityFlag::Faulty,
obis_code: None,
};
let period = aggregate(&[faulty], &AggregationConfig::rlm());
assert_eq!(period.spitzenleistung_kw, None);
assert_eq!(period.benutzungsdauer_h(), None);
assert!(
period.uniform_resolution,
"vacuously, with nothing billable"
);
}
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)));
}
}