use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::precision::{BENUTZUNGSDAUER_DP, BENUTZUNGSDAUER_STRATEGY};
use crate::series::Series;
use crate::series::interval::MeterInterval;
use crate::time::calendar::Period;
use crate::vee::validation::{Coverage, coverage};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct BillingPeriod {
pub period: Period,
#[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 outside_count: usize,
pub coverage: Coverage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("the Arbeitsmenge overflows")]
#[non_exhaustive]
pub struct Overflow;
pub const STROMNEV_AUSSERKRAFT: time::Date =
match time::Date::from_calendar_date(2028, time::Month::December, 31) {
Ok(d) => d,
Err(_) => unreachable!(),
};
pub fn aggregate(series: &Series, period: Period) -> Result<BillingPeriod, Overflow> {
let peaks = series
.resolution()
.fixed_seconds()
.is_some_and(|s| s <= 3600);
let inside = |iv: &MeterInterval| period.start() <= iv.from() && iv.to() <= period.end();
let mut out = BillingPeriod {
period,
arbeitsmenge: Decimal::ZERO,
spitzenleistung_kw: None,
spitzenleistung_at: None,
billable_count: 0,
excluded_count: 0,
outside_count: 0,
coverage: coverage(series, &period, |iv| {
iv.quality().is_billable() && inside(iv)
}),
};
for iv in series {
if !inside(iv) {
out.outside_count += 1;
continue;
}
if !iv.quality().is_billable() {
out.excluded_count += 1;
continue;
}
out.billable_count += 1;
out.arbeitsmenge = out.arbeitsmenge.checked_add(iv.value()).ok_or(Overflow)?;
if peaks
&& let Some(kw) = iv.demand_kw()
&& out.spitzenleistung_kw.is_none_or(|best| kw > best)
{
out.spitzenleistung_kw = Some(kw);
out.spitzenleistung_at = Some(iv.from());
}
}
Ok(out)
}
#[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
.checked_div(peak)?
.round_dp_with_strategy(BENUTZUNGSDAUER_DP, BENUTZUNGSDAUER_STRATEGY),
)
}
}
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::series::interval::Direction::Import) => out.import += iv.value(),
Some(crate::series::interval::Direction::Export) => out.export += iv.value(),
None => out.undirected += iv.value(),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::QualityFlag;
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
use rust_decimal::dec;
use time::Duration;
use time::macros::date;
fn day() -> Period {
DayBoundary::Strom.day(date!(2026 - 06 - 01)).unwrap()
}
fn series(values: &[(i64, Decimal, QualityFlag)]) -> Series {
let ivs = values
.iter()
.map(|(i, v, q)| {
MeterInterval::quarter_hour(day().start() + Duration::minutes(15 * i), *v, *q)
.unwrap()
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn the_stromnev_basis_has_a_stated_end_date() {
assert_eq!(STROMNEV_AUSSERKRAFT, date!(2028 - 12 - 31));
}
#[test]
fn only_billable_values_inside_the_period_count_and_the_first_peak_wins() {
let m = QualityFlag::Measured;
let s = series(&[
(0, dec!(5), m),
(1, dec!(9), QualityFlag::Faulty),
(2, dec!(5), m),
(96, dec!(7), m), ]);
let p = aggregate(&s, day()).unwrap();
assert_eq!(p.arbeitsmenge, dec!(10));
assert_eq!(
(p.billable_count, p.excluded_count, p.outside_count),
(2, 1, 1)
);
assert_eq!(p.spitzenleistung_kw, Some(dec!(20)));
assert_eq!(p.spitzenleistung_at, Some(day().start()));
assert_eq!(p.benutzungsdauer_h(), Some(dec!(0.5)));
}
#[test]
fn coverage_is_clipped_to_the_period() {
let m = QualityFlag::Measured;
let s = series(&(0..200).map(|i| (i, dec!(1), m)).collect::<Vec<_>>());
let p = aggregate(&s, day()).unwrap();
assert_eq!(p.coverage.pct(), Some(dec!(100)));
assert!(p.coverage.is_complete());
assert_eq!(p.outside_count, 104);
}
#[test]
fn a_daily_series_has_no_spitzenleistung() {
let iv = MeterInterval::new(day().start(), day().end(), dec!(24), QualityFlag::Measured)
.unwrap();
let s = Series::new(Resolution::Day, DayBoundary::Strom, vec![iv]).unwrap();
let p = aggregate(&s, day()).unwrap();
assert_eq!(p.arbeitsmenge, dec!(24));
assert_eq!(p.spitzenleistung_kw, None);
assert_eq!(p.benutzungsdauer_h(), None);
}
#[test]
fn an_empty_series_is_zero_with_zero_coverage() {
let s = series(&[]);
let p = aggregate(&s, day()).unwrap();
assert_eq!(p.arbeitsmenge, Decimal::ZERO);
assert_eq!(p.coverage.pct(), Some(dec!(0)));
}
#[test]
fn overflow_is_an_error() {
let m = QualityFlag::Measured;
let s = series(&[(0, Decimal::MAX, m), (1, Decimal::MAX, m)]);
assert_eq!(aggregate(&s, day()), Err(Overflow));
}
}