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 (§ 60 Abs. 2 MsbG)"
}
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>,
}
fn confidence_half_width(
intervals: &[MeterInterval],
seasonal_factor: Decimal,
year_days: u16,
) -> Option<Decimal> {
use std::collections::BTreeMap;
let mut daily: BTreeMap<time::Date, Decimal> = BTreeMap::new();
for iv in intervals.iter().filter(|iv| iv.quality.is_billable()) {
*daily.entry(iv.berlin_day()).or_insert(Decimal::ZERO) += iv.value_kwh;
}
if daily.len() < 2 {
return None;
}
let n = daily.len() as f64;
let values: Vec<f64> = daily
.values()
.filter_map(|d| d.to_string().parse::<f64>().ok())
.collect();
if values.len() != daily.len() {
return None;
}
let mean = values.iter().sum::<f64>() / n;
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
let sd_daily = var.sqrt();
let factor: f64 = seasonal_factor.to_string().parse().ok()?;
let half = 1.96 * sd_daily * f64::from(year_days).sqrt() * factor;
if !half.is_finite() {
return None;
}
Decimal::try_from(half).ok().map(|d| d.round_dp(3))
}
#[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 = crate::calendar::days_between(first_from, last_to);
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 target_year = crate::calendar::local_year(last_to);
let year_days = crate::calendar::days_in_year(target_year);
let daily_avg = observed_kwh / Decimal::from(observed_days);
let mut projected = daily_avg * Decimal::from(year_days);
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: confidence_half_width(intervals, seasonal_factor, year_days)
.map(|h| (projected - h).max(Decimal::ZERO).round_dp(3)),
confidence_upper_kwh: confidence_half_width(intervals, seasonal_factor, year_days)
.map(|h| (projected + h).round_dp(3)),
})
}
#[must_use]
pub fn substitute_values(
gap_from: OffsetDateTime,
gap_to: OffsetDateTime,
interval_secs: u32,
method: crate::substitute::SubstituteMethod,
prior_period_intervals: &[MeterInterval],
last_known_value: Option<Decimal>,
next_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, applied, ref_count) = match method {
crate::substitute::SubstituteMethod::ZeroFill => {
(Decimal::ZERO, ForecastMethod::ZeroFill, 0)
}
crate::substitute::SubstituteMethod::LastValueCarryForward => match last_known_value {
Some(last) => (last, ForecastMethod::LastValueCarryForward, 1),
None => (Decimal::ZERO, ForecastMethod::ZeroFill, 0),
},
crate::substitute::SubstituteMethod::LinearInterpolation => {
match (last_known_value, next_known_value) {
(Some(start), Some(end)) => {
let span = (gap_to - gap_from).whole_seconds();
let elapsed = (cursor - gap_from).whole_seconds();
let value = if span > 0 {
start + (end - start) * Decimal::from(elapsed) / Decimal::from(span)
} else {
start
};
(value, ForecastMethod::LinearInterpolation, 2)
}
(Some(start), None) => (start, ForecastMethod::LastValueCarryForward, 1),
(None, Some(end)) => (end, ForecastMethod::LastValueCarryForward, 1),
(None, None) => (Decimal::ZERO, ForecastMethod::ZeroFill, 0),
}
}
crate::substitute::SubstituteMethod::PriorPeriodAverage => {
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)
}
}
};
let method = applied;
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: match applied {
ForecastMethod::PriorPeriodSameSlot => Some(format!(
"{ref_count} Referenzintervall(e) im Vorperiodenmittelwert"
)),
ForecastMethod::LinearInterpolation => {
Some("lineare Interpolation zwischen den Randwerten".to_owned())
}
ForecastMethod::LastValueCarryForward => {
Some("letzter bekannter Messwert fortgeschrieben".to_owned())
}
ForecastMethod::ZeroFill => {
Some("kein Referenzwert verfügbar — Nullwert gesetzt".to_owned())
}
_ => 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 prior_from = crate::calendar::shift_back_one_year(obs_from);
let prior_to = crate::calendar::shift_back_one_year(obs_to);
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(crate::calendar::days_between(obs_from, obs_to).max(1) as u32);
let prior_year_days = crate::calendar::days_in_year(crate::calendar::local_year(prior_to));
let prior_daily = prior_annual_kwh / Decimal::from(prior_year_days);
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::dec;
use time::{
Duration,
macros::{date, 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 confidence_bounds_bracket_the_projection() {
let base = datetime!(2026-01-01 00:00 UTC);
let intervals: Vec<_> = (0..14 * 96)
.map(|i| {
let day = i / 96;
let kwh = if day % 2 == 0 { dec!(1.0) } else { dec!(1.4) };
make_iv(base + Duration::minutes(15 * i), kwh)
})
.collect();
let f = project_annual_consumption("51238696781", &intervals, None).expect("forecast");
let lower = f.confidence_lower_kwh.expect("lower bound computed");
let upper = f.confidence_upper_kwh.expect("upper bound computed");
assert!(lower < f.projected_annual_kwh && f.projected_annual_kwh < upper);
assert!(lower >= Decimal::ZERO, "lower bound clamped at zero");
}
#[test]
fn constant_consumption_has_a_tight_interval() {
let base = crate::calendar::day_start_utc(time::macros::date!(2026 - 01 - 01));
let intervals: Vec<_> = (0..14 * 96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let f = project_annual_consumption("51238696781", &intervals, None).expect("forecast");
assert_eq!(f.confidence_lower_kwh, Some(f.projected_annual_kwh));
assert_eq!(f.confidence_upper_kwh, Some(f.projected_annual_kwh));
}
#[test]
fn observation_window_spanning_dst_counts_calendar_days() {
let window = |start: time::Date| {
let base = crate::calendar::day_start_utc(start);
let count = (0..14)
.map(|d| {
crate::calendar::intervals_in_day(
start.checked_add(Duration::days(d)).unwrap(),
crate::resolution::IntervalResolution::QuarterHour,
)
.unwrap()
})
.sum::<u32>();
(0..i64::from(count))
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1)))
.collect::<Vec<_>>()
};
let across_dst = window(date!(2026 - 03 - 23)); let ordinary = window(date!(2026 - 06 - 01));
let a = project_annual_consumption("51238696781", &across_dst, None).unwrap();
let b = project_annual_consumption("51238696781", &ordinary, None).unwrap();
assert_eq!(a.observed_days, 14, "fourteen calendar days, not thirteen");
assert_eq!(b.observed_days, 14);
let ratio = a.projected_annual_kwh / b.projected_annual_kwh;
assert!(
ratio > dec!(0.997) && ratio < dec!(1.0),
"projections must differ only by the lost hour, got ratio {ratio}"
);
}
#[test]
fn projection_scales_to_the_real_year_length() {
let daily_kwh = dec!(96); let series = |start_year| {
let base = crate::calendar::day_start_utc(
time::Date::from_calendar_date(start_year, time::Month::January, 1).unwrap(),
);
(0..14 * 96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect::<Vec<_>>()
};
let common = project_annual_consumption("51238696781", &series(2026), None).unwrap();
assert_eq!(common.projected_annual_kwh, daily_kwh * dec!(365));
let leap = project_annual_consumption("51238696781", &series(2028), None).unwrap();
assert_eq!(
leap.projected_annual_kwh,
daily_kwh * dec!(366),
"2028 is a leap year — 366 days of consumption, not 365"
);
assert_eq!(
leap.projected_annual_kwh - common.projected_annual_kwh,
daily_kwh,
"the difference is exactly one day"
);
}
#[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 = substitute_values(
gap_from,
gap_to,
900,
crate::substitute::SubstituteMethod::PriorPeriodAverage,
&prior,
None,
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 = substitute_values(
gap_from,
gap_to,
900,
crate::substitute::SubstituteMethod::PriorPeriodAverage,
&[],
Some(dec!(2.5)),
None,
);
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());
}
}
}
#[cfg(test)]
mod db_vocabulary_tests {
use super::ForecastMethod;
#[test]
fn every_variant_is_accounted_for() {
let all = [
ForecastMethod::PriorPeriodSameSlot,
ForecastMethod::WeightedRollingAverage,
ForecastMethod::LinearInterpolation,
ForecastMethod::LastValueCarryForward,
ForecastMethod::ZeroFill,
ForecastMethod::ProfileBased,
ForecastMethod::AnnualProjection,
];
assert_eq!(all.len(), 7, "ForecastMethod gained or lost a variant");
}
#[test]
fn each_requested_strategy_is_the_one_applied() {
use super::substitute_values;
use crate::interval::{MeterInterval, QualityFlag};
use crate::substitute::SubstituteMethod;
use rust_decimal::dec;
use time::macros::datetime;
let from = datetime!(2026-03-09 00:00 UTC);
let to = datetime!(2026-03-09 01:00 UTC);
let prior = vec![MeterInterval {
from: datetime!(2026-03-02 00:00 UTC),
to: datetime!(2026-03-02 00:15 UTC),
value_kwh: dec!(99),
quality: QualityFlag::Measured,
obis_code: None,
}];
let zero = substitute_values(
from,
to,
900,
SubstituteMethod::ZeroFill,
&prior,
Some(dec!(50)),
Some(dec!(70)),
);
assert_eq!(zero.len(), 4);
assert!(
zero.iter().all(|e| e.interval.value_kwh == dec!(0)),
"ZeroFill must produce zeros regardless of available reference data"
);
assert!(
zero.iter().all(|e| e.method == ForecastMethod::ZeroFill),
"the reported method must be the one applied"
);
let carry = substitute_values(
from,
to,
900,
SubstituteMethod::LastValueCarryForward,
&prior,
Some(dec!(50)),
Some(dec!(70)),
);
assert!(carry.iter().all(|e| e.interval.value_kwh == dec!(50)));
let linear = substitute_values(
from,
to,
900,
SubstituteMethod::LinearInterpolation,
&prior,
Some(dec!(0)),
Some(dec!(100)),
);
let values: Vec<_> = linear.iter().map(|e| e.interval.value_kwh).collect();
assert_eq!(values, vec![dec!(0), dec!(25), dec!(50), dec!(75)]);
assert!(
linear
.iter()
.all(|e| e.method == ForecastMethod::LinearInterpolation)
);
}
#[test]
fn a_strategy_with_no_data_reports_what_it_fell_back_to() {
use super::substitute_values;
use crate::substitute::SubstituteMethod;
use rust_decimal::dec;
use time::macros::datetime;
let entries = substitute_values(
datetime!(2026-03-09 00:00 UTC),
datetime!(2026-03-09 00:15 UTC),
900,
SubstituteMethod::LinearInterpolation,
&[],
Some(dec!(42)),
None,
);
assert_eq!(entries[0].interval.value_kwh, dec!(42));
assert_eq!(
entries[0].method,
ForecastMethod::LastValueCarryForward,
"the audit record must name the fallback that ran, not the request"
);
}
}