use rust_decimal::Decimal;
use std::collections::HashMap;
use time::{Duration, OffsetDateTime, Weekday};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::{MeterInterval, QualityFlag};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ForecastMethod {
PriorPeriodSameSlot,
WeightedRollingAverage,
LinearInterpolation,
LastValueCarryForward,
ZeroFill,
ProfileBased,
AnnualProjection,
}
impl ForecastMethod {
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::PriorPeriodSameSlot => {
"Vorperiodenmittelwert gleicher Zeitschlitz (§17 Abs. 2 MessZV)"
}
Self::WeightedRollingAverage => "Gewichteter gleitender Mittelwert",
Self::LinearInterpolation => "Lineare Interpolation zwischen Messwerten",
Self::LastValueCarryForward => "Letzter bekannter Wert fortgeschrieben",
Self::ZeroFill => "Nullwert (bestätigter Lieferstopp)",
Self::ProfileBased => "Profilbasiert (BDEW Standardlastprofil)",
Self::AnnualProjection => "Jahreshochrechnung aus Teiljahreswerten",
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SubstituteValueEntry {
pub interval: MeterInterval,
pub method: ForecastMethod,
pub reference_count: u32,
pub confidence_note: Option<String>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AnnualForecast {
pub malo_id: String,
pub observation_from: OffsetDateTime,
pub observation_to: OffsetDateTime,
pub observed_kwh: Decimal,
pub observed_days: u32,
pub projected_annual_kwh: Decimal,
pub seasonal_correction_applied: bool,
pub seasonal_factor: Decimal,
pub method: ForecastMethod,
pub confidence_lower_kwh: Option<Decimal>,
pub confidence_upper_kwh: Option<Decimal>,
}
#[must_use]
pub fn project_annual_consumption(
malo_id: &str,
intervals: &[MeterInterval],
prior_year_intervals: Option<&[MeterInterval]>,
) -> Option<AnnualForecast> {
if intervals.is_empty() {
return None;
}
let first_from = intervals.iter().map(|iv| iv.from).min()?;
let last_to = intervals.iter().map(|iv| iv.to).max()?;
let observed_days_i64 = (last_to - first_from).whole_days();
if observed_days_i64 < 7 {
return None; }
let observed_days = observed_days_i64 as u32;
let observed_kwh: Decimal = intervals
.iter()
.filter(|iv| iv.quality.is_billable())
.map(|iv| iv.value_kwh)
.sum();
let daily_avg = observed_kwh / Decimal::from(observed_days);
let mut projected = daily_avg * Decimal::from(365u32);
let (seasonal_correction_applied, seasonal_factor) = if let Some(prior) = prior_year_intervals {
if let Some(factor) = compute_seasonal_factor(first_from, last_to, prior) {
projected *= factor;
(true, factor)
} else {
(false, Decimal::ONE)
}
} else {
(false, Decimal::ONE)
};
Some(AnnualForecast {
malo_id: malo_id.to_owned(),
observation_from: first_from,
observation_to: last_to,
observed_kwh,
observed_days,
projected_annual_kwh: projected.round_dp(3),
seasonal_correction_applied,
seasonal_factor,
method: if prior_year_intervals.is_some() {
ForecastMethod::WeightedRollingAverage
} else {
ForecastMethod::AnnualProjection
},
confidence_lower_kwh: None,
confidence_upper_kwh: None,
})
}
#[must_use]
pub fn prior_period_substitutes(
gap_from: OffsetDateTime,
gap_to: OffsetDateTime,
interval_secs: u32,
prior_period_intervals: &[MeterInterval],
last_known_value: Option<Decimal>,
) -> Vec<SubstituteValueEntry> {
if gap_from >= gap_to || interval_secs == 0 {
return Vec::new();
}
let mut slot_map: HashMap<(Weekday, u8, u8), Vec<Decimal>> = HashMap::new();
for iv in prior_period_intervals {
if iv.quality.is_billable() {
use time_tz::{OffsetDateTimeExt, timezones};
let local = iv.from.to_timezone(timezones::db::europe::BERLIN);
let key = (local.weekday(), local.hour(), local.minute());
slot_map.entry(key).or_default().push(iv.value_kwh);
}
}
let mut result = Vec::new();
let interval_dur = Duration::seconds(i64::from(interval_secs));
let mut cursor = gap_from;
while cursor < gap_to {
let to = (cursor + interval_dur).min(gap_to);
use time_tz::{OffsetDateTimeExt, timezones};
let local = cursor.to_timezone(timezones::db::europe::BERLIN);
let key = (local.weekday(), local.hour(), local.minute());
let (value, method, ref_count) = if let Some(prior_values) = slot_map.get(&key) {
let avg =
prior_values.iter().sum::<Decimal>() / Decimal::from(prior_values.len() as u32);
(
avg,
ForecastMethod::PriorPeriodSameSlot,
prior_values.len() as u32,
)
} else if let Some(last) = last_known_value {
(last, ForecastMethod::LastValueCarryForward, 1)
} else {
(Decimal::ZERO, ForecastMethod::ZeroFill, 0)
};
result.push(SubstituteValueEntry {
interval: MeterInterval {
from: cursor,
to,
value_kwh: value.round_dp(6),
quality: QualityFlag::Substituted,
obis_code: None,
},
method,
reference_count: ref_count,
confidence_note: if ref_count > 0 {
Some(format!(
"{ref_count} Referenzintervall(e) im Vorperiodenmittelwert"
))
} else {
None
},
});
cursor = to;
}
result
}
fn compute_seasonal_factor(
obs_from: OffsetDateTime,
obs_to: OffsetDateTime,
prior_year: &[MeterInterval],
) -> Option<Decimal> {
if prior_year.is_empty() {
return None;
}
let one_year = Duration::days(365);
let prior_from = obs_from - one_year;
let prior_to = obs_to - one_year;
let prior_period_kwh: Decimal = prior_year
.iter()
.filter(|iv| iv.from >= prior_from && iv.to <= prior_to && iv.quality.is_billable())
.map(|iv| iv.value_kwh)
.sum();
let prior_annual_kwh: Decimal = prior_year
.iter()
.filter(|iv| iv.quality.is_billable())
.map(|iv| iv.value_kwh)
.sum();
if prior_annual_kwh == Decimal::ZERO || prior_period_kwh == Decimal::ZERO {
return None;
}
let period_days = Decimal::from((obs_to - obs_from).whole_days().max(1) as u32);
let prior_daily = prior_annual_kwh / Decimal::from(365u32);
let prior_period_daily = prior_period_kwh / period_days;
if prior_daily == Decimal::ZERO {
return None;
}
Some((prior_period_daily / prior_daily).round_dp(4))
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use time::{Duration, macros::datetime};
fn make_iv(from: OffsetDateTime, kwh: Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + Duration::minutes(15),
value_kwh: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn annual_projection_simple() {
let base = datetime!(2026-01-01 00:00 UTC);
let intervals: Vec<_> =
(0..30 * 96) .map(|i| make_iv(base + Duration::minutes(15 * i), dec!(2.0)))
.collect();
let forecast = project_annual_consumption("51238696780", &intervals, None).unwrap();
assert!(forecast.observed_days >= 30);
assert!(forecast.projected_annual_kwh > dec!(60000));
assert_eq!(forecast.method, ForecastMethod::AnnualProjection);
assert!(!forecast.seasonal_correction_applied);
}
#[test]
fn insufficient_data_returns_none() {
let base = datetime!(2026-01-01 00:00 UTC);
let intervals = vec![make_iv(base, dec!(1.0))];
assert!(project_annual_consumption("test", &intervals, None).is_none());
}
#[test]
fn empty_intervals_returns_none() {
assert!(project_annual_consumption("test", &[], None).is_none());
}
#[test]
fn prior_period_substitution_uses_same_slot() {
let base = datetime!(2026-01-01 00:00 UTC);
let prior: Vec<_> = (0..7 * 96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.5)))
.collect();
let gap_from = datetime!(2026-01-08 00:00 UTC);
let gap_to = datetime!(2026-01-08 01:00 UTC);
let subs = prior_period_substitutes(gap_from, gap_to, 900, &prior, None);
assert_eq!(subs.len(), 4);
for s in &subs {
assert_eq!(s.method, ForecastMethod::PriorPeriodSameSlot);
assert!((s.interval.value_kwh - dec!(1.5)).abs() < dec!(0.001));
assert_eq!(s.interval.quality, QualityFlag::Substituted);
}
}
#[test]
fn prior_period_fallback_to_carry_forward() {
let gap_from = datetime!(2026-06-01 00:00 UTC);
let gap_to = datetime!(2026-06-01 00:15 UTC);
let subs = prior_period_substitutes(gap_from, gap_to, 900, &[], Some(dec!(2.5)));
assert_eq!(subs.len(), 1);
assert_eq!(subs[0].method, ForecastMethod::LastValueCarryForward);
assert_eq!(subs[0].interval.value_kwh, dec!(2.5));
}
#[test]
fn forecast_method_descriptions_non_empty() {
for m in [
ForecastMethod::PriorPeriodSameSlot,
ForecastMethod::WeightedRollingAverage,
ForecastMethod::LinearInterpolation,
ForecastMethod::LastValueCarryForward,
ForecastMethod::ZeroFill,
ForecastMethod::ProfileBased,
ForecastMethod::AnnualProjection,
] {
assert!(!m.description().is_empty());
}
}
}