use rust_decimal::Decimal;
use time::OffsetDateTime;
use super::{EegError, align, coincident, sum};
use crate::series::Series;
use crate::series::interval::QualityFlag;
use crate::time::calendar::DayBoundary;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CoincidentInterval {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub consumption: Decimal,
pub withdrawal: Decimal,
pub counted: Decimal,
pub quality: QualityFlag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Zeitgleichheit {
pub intervals: Vec<CoincidentInterval>,
pub total: Decimal,
}
impl Zeitgleichheit {
pub fn not_measured(&self) -> impl Iterator<Item = &CoincidentInterval> {
self.intervals
.iter()
.filter(|iv| iv.quality != QualityFlag::Measured)
}
}
pub fn zeitgleichheit(
consumption: &Series,
withdrawal: &Series,
) -> Result<Zeitgleichheit, EegError> {
let rows = align(&[("consumption", consumption), ("withdrawal", withdrawal)])?;
let intervals: Vec<CoincidentInterval> = rows
.into_iter()
.map(|r| CoincidentInterval {
from: r.from,
to: r.to,
consumption: r.values[0],
withdrawal: r.values[1],
counted: coincident(r.values[0], r.values[1]),
quality: r.quality,
})
.collect();
let total = sum(intervals.iter().map(|iv| iv.counted))?;
Ok(Zeitgleichheit { intervals, total })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct WorstCaseEstimate {
pub hours: u32,
pub kwh: Decimal,
}
#[must_use]
pub fn worst_case_estimate(max_power_kw: Decimal, year: i32) -> Option<WorstCaseEstimate> {
if max_power_kw < Decimal::ZERO {
return None;
}
let period = DayBoundary::Strom.year(year)?;
let hours = u32::try_from(period.duration().whole_hours()).ok()?;
let kwh = max_power_kw.checked_mul(Decimal::from(hours))?;
Some(WorstCaseEstimate { hours, kwh })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::MeterInterval;
use crate::time::resolution::Resolution;
use rust_decimal::dec;
use time::Duration;
use time::macros::datetime;
fn series(values: &[Decimal]) -> Series {
let t = datetime!(2026-03-29 0:00 UTC);
let ivs = values
.iter()
.enumerate()
.map(|(i, &v)| {
MeterInterval::quarter_hour(
t + Duration::minutes(15 * i as i64),
v,
QualityFlag::Measured,
)
.unwrap()
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn minimum_per_interval_before_the_sum() {
let c = series(&[dec!(5), dec!(0), dec!(3)]);
let w = series(&[dec!(1), dec!(4), dec!(3)]);
let z = zeitgleichheit(&c, &w).unwrap();
assert_eq!(z.total, dec!(4));
let min_of_sums = dec!(8).min(dec!(8));
assert_ne!(z.total, min_of_sums);
assert_eq!(
z.intervals.iter().map(|i| i.counted).collect::<Vec<_>>(),
[dec!(1), dec!(0), dec!(3)]
);
}
#[test]
fn substituted_inputs_are_reported() {
let c = series(&[dec!(1), dec!(1)]);
let t = datetime!(2026-03-29 0:00 UTC);
let w = Series::new(
Resolution::QUARTER_HOUR,
DayBoundary::Strom,
vec![
MeterInterval::quarter_hour(t, dec!(1), QualityFlag::Measured).unwrap(),
MeterInterval::quarter_hour(
t + Duration::minutes(15),
dec!(1),
QualityFlag::Substituted,
)
.unwrap(),
],
)
.unwrap();
let z = zeitgleichheit(&c, &w).unwrap();
assert_eq!(z.total, dec!(2));
let flagged: Vec<_> = z.not_measured().map(|i| i.from).collect();
assert_eq!(flagged, [t + Duration::minutes(15)]);
}
#[test]
fn the_estimate_uses_the_calendar_years_hours() {
assert_eq!(worst_case_estimate(dec!(1), 2026).unwrap().hours, 8_760);
assert_eq!(worst_case_estimate(dec!(1), 2028).unwrap().hours, 8_784);
assert_eq!(
worst_case_estimate(dec!(10), 2027).unwrap().kwh,
dec!(87600)
);
assert!(worst_case_estimate(dec!(-1), 2026).is_none());
assert!(worst_case_estimate(dec!(1), 1800).is_none());
}
}