use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive as _;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::MeterInterval;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AnnualForecast {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub observation_from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub observation_to: OffsetDateTime,
pub observed: Decimal,
pub observed_days: u32,
pub target_year_days: u16,
pub projected_annual: Decimal,
pub seasonal_factor: Decimal,
pub seasonal_correction_applied: bool,
pub confidence_lower: Option<Decimal>,
pub confidence_upper: Option<Decimal>,
}
impl AnnualForecast {
#[must_use]
pub const fn prediction_interval_note() -> &'static str {
"95 % interval from daily-sum variability, assuming independent days; \
real load profiles are autocorrelated and seasonal, so the true spread is wider"
}
#[must_use]
pub fn daily_average_kwh(&self) -> Decimal {
if self.observed_days == 0 {
return Decimal::ZERO;
}
self.observed / Decimal::from(self.observed_days)
}
}
pub const FORECAST_DP: u32 = 3;
pub const MIN_OBSERVATION_DAYS: i64 = 7;
#[must_use]
pub fn project_annual_consumption(
intervals: &[MeterInterval],
prior_year_intervals: Option<&[MeterInterval]>,
) -> Option<AnnualForecast> {
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 < MIN_OBSERVATION_DAYS {
return None;
}
let observed_days = u32::try_from(observed_days_i64).ok()?;
let observed: Decimal = intervals
.iter()
.filter(|iv| iv.quality.is_billable())
.map(|iv| iv.value)
.sum();
let target_year = crate::calendar::local_year(last_to);
let target_year_days = crate::calendar::days_in_year(target_year);
let daily_avg = observed / Decimal::from(observed_days);
let derived_factor =
prior_year_intervals.and_then(|prior| seasonal_factor(first_from, last_to, prior));
let seasonal_correction_applied = derived_factor.is_some();
let seasonal_factor = derived_factor.unwrap_or(Decimal::ONE);
let projected = daily_avg * Decimal::from(target_year_days) * seasonal_factor;
let half_width = prediction_half_width(intervals, seasonal_factor, target_year_days);
Some(AnnualForecast {
observation_from: first_from,
observation_to: last_to,
observed,
observed_days,
target_year_days,
projected_annual: projected.round_dp(FORECAST_DP),
seasonal_factor,
seasonal_correction_applied,
confidence_lower: half_width
.map(|h| (projected - h).max(Decimal::ZERO).round_dp(FORECAST_DP)),
confidence_upper: half_width.map(|h| (projected + h).round_dp(FORECAST_DP)),
})
}
fn prediction_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;
}
if daily.len() < 2 {
return None;
}
let values: Vec<f64> = daily.values().filter_map(Decimal::to_f64).collect();
if values.len() != daily.len() {
return None;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
let y = f64::from(year_days);
let total_variance = variance * (y * y / n + y);
let factor = seasonal_factor.to_f64()?;
let half = 1.96 * total_variance.sqrt() * factor.abs();
if !half.is_finite() {
return None;
}
Decimal::try_from(half)
.ok()
.map(|d| d.round_dp(FORECAST_DP))
}
fn 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 billable = |iv: &&MeterInterval| iv.quality.is_billable();
let prior_window_kwh: Decimal = prior_year
.iter()
.filter(billable)
.filter(|iv| iv.from >= prior_from && iv.to <= prior_to)
.map(|iv| iv.value)
.sum();
let prior_total_kwh: Decimal = prior_year.iter().filter(billable).map(|iv| iv.value).sum();
if prior_window_kwh.is_zero() || prior_total_kwh.is_zero() {
return None;
}
let window_days = crate::calendar::days_between(prior_from, prior_to).max(1);
let prior_first = prior_year.iter().map(|iv| iv.from).min()?;
let prior_last = prior_year.iter().map(|iv| iv.to).max()?;
let reference_days = crate::calendar::days_between(prior_first, prior_last).max(1);
let window_daily = prior_window_kwh / Decimal::from(window_days);
let reference_daily = prior_total_kwh / Decimal::from(reference_days);
if reference_daily.is_zero() {
return None;
}
Some((window_daily / reference_daily).round_dp(4))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
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,
quality: QualityFlag::Measured,
obis_code: None,
}
}
fn aligned_days(start: time::Date, days: i64, kwh: Decimal) -> Vec<MeterInterval> {
let base = crate::calendar::day_start_utc(start);
(0..days * 96)
.map(|i| make_iv(base + Duration::minutes(15 * i), kwh))
.collect()
}
#[test]
fn a_flat_fortnight_projects_the_flat_year() {
let intervals = aligned_days(date!(2026 - 01 - 01), 14, dec!(1));
let f = project_annual_consumption(&intervals, None).unwrap();
assert_eq!(f.observed_days, 14);
assert_eq!(f.daily_average_kwh(), dec!(96));
assert_eq!(f.projected_annual, dec!(96) * dec!(365));
assert!(!f.seasonal_correction_applied);
assert_eq!(f.seasonal_factor, Decimal::ONE);
}
#[test]
fn insufficient_data_returns_none() {
assert!(project_annual_consumption(&[], None).is_none());
let one = vec![make_iv(datetime!(2026-01-01 00:00 UTC), dec!(1))];
assert!(project_annual_consumption(&one, None).is_none());
assert!(
project_annual_consumption(&aligned_days(date!(2026 - 01 - 01), 6, dec!(1)), None)
.is_none()
);
assert!(
project_annual_consumption(&aligned_days(date!(2026 - 01 - 01), 7, dec!(1)), None)
.is_some()
);
}
#[test]
fn projection_scales_to_the_real_year_length() {
let common =
project_annual_consumption(&aligned_days(date!(2026 - 01 - 01), 14, dec!(1)), None)
.unwrap();
let leap =
project_annual_consumption(&aligned_days(date!(2028 - 01 - 01), 14, dec!(1)), None)
.unwrap();
assert_eq!(common.target_year_days, 365);
assert_eq!(leap.target_year_days, 366);
assert_eq!(
leap.projected_annual - common.projected_annual,
dec!(96),
"the difference is exactly one day"
);
}
#[test]
fn observation_window_spanning_dst_counts_calendar_days() {
let calendar_days = |start: time::Date, days: i64| {
let base = crate::calendar::day_start_utc(start);
let count: u32 = (0..days)
.map(|d| {
crate::calendar::intervals_in_day(
start.checked_add(Duration::days(d)).unwrap(),
crate::IntervalResolution::QuarterHour,
)
.unwrap()
})
.sum();
(0..i64::from(count))
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1)))
.collect::<Vec<_>>()
};
let across_dst = calendar_days(date!(2026 - 03 - 23), 14); let ordinary = calendar_days(date!(2026 - 06 - 01), 14);
assert_eq!(across_dst.len() + 4, ordinary.len());
let a = project_annual_consumption(&across_dst, None).unwrap();
let b = project_annual_consumption(&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 / b.projected_annual;
assert!(
ratio > dec!(0.997) && ratio < dec!(1.0),
"projections must differ only by the lost hour, got {ratio}"
);
}
#[test]
fn bounds_bracket_the_projection() {
let base = crate::calendar::day_start_utc(date!(2026 - 01 - 01));
let intervals: Vec<_> = (0..14 * 96)
.map(|i| {
let kwh = if (i / 96) % 2 == 0 {
dec!(1.0)
} else {
dec!(1.4)
};
make_iv(base + Duration::minutes(15 * i), kwh)
})
.collect();
let f = project_annual_consumption(&intervals, None).unwrap();
let lower = f.confidence_lower.unwrap();
let upper = f.confidence_upper.unwrap();
assert!(lower < f.projected_annual && f.projected_annual < upper);
assert!(lower >= Decimal::ZERO, "lower bound clamped at zero");
}
#[test]
fn constant_consumption_has_a_zero_width_interval() {
let f =
project_annual_consumption(&aligned_days(date!(2026 - 01 - 01), 14, dec!(1.0)), None)
.unwrap();
assert_eq!(f.confidence_lower, Some(f.projected_annual));
assert_eq!(f.confidence_upper, Some(f.projected_annual));
}
#[test]
fn the_interval_includes_the_estimation_error() {
let base = crate::calendar::day_start_utc(date!(2026 - 01 - 01));
let intervals: Vec<_> = (0..14 * 96)
.map(|i| {
let kwh = if (i / 96) % 2 == 0 {
dec!(1.0)
} else {
dec!(2.0)
};
make_iv(base + Duration::minutes(15 * i), kwh)
})
.collect();
let f = project_annual_consumption(&intervals, None).unwrap();
let half = (f.confidence_upper.unwrap() - f.projected_annual)
.to_f64()
.unwrap();
let sigma = 49.9_f64;
let y = 365.0_f64;
let old = 1.96 * sigma * y.sqrt(); let new = 1.96 * (sigma * sigma * (y * y / 14.0 + y)).sqrt();
assert!(
(half - new).abs() / new < 0.05,
"half-width {half:.0} should be near {new:.0}"
);
assert!(
half > old * 4.0,
"the corrected interval must be several times the old one: {half:.0} vs {old:.0}"
);
}
#[test]
fn a_longer_window_narrows_the_interval() {
let alternating = |days: i64| {
let base = crate::calendar::day_start_utc(date!(2026 - 01 - 01));
(0..days * 96)
.map(|i| {
let kwh = if (i / 96) % 2 == 0 {
dec!(1.0)
} else {
dec!(2.0)
};
make_iv(base + Duration::minutes(15 * i), kwh)
})
.collect::<Vec<_>>()
};
let short = project_annual_consumption(&alternating(14), None).unwrap();
let long = project_annual_consumption(&alternating(120), None).unwrap();
let width = |f: &AnnualForecast| f.confidence_upper.unwrap() - f.confidence_lower.unwrap();
assert!(
width(&long) < width(&short),
"120 days must give a tighter interval than 14: {} vs {}",
width(&long),
width(&short)
);
}
#[test]
fn seasonal_correction_scales_a_winter_window_down() {
let base = crate::calendar::day_start_utc(date!(2025 - 01 - 01));
let prior: Vec<_> = (0..365 * 96)
.map(|i| {
let kwh = if i < 31 * 96 { dec!(2) } else { dec!(1) };
make_iv(base + Duration::minutes(15 * i), kwh)
})
.collect();
let observed = aligned_days(date!(2026 - 01 - 05), 14, dec!(2));
let uncorrected = project_annual_consumption(&observed, None).unwrap();
let corrected = project_annual_consumption(&observed, Some(&prior)).unwrap();
assert!(corrected.seasonal_correction_applied);
assert!(
corrected.seasonal_factor > dec!(1.5) && corrected.seasonal_factor < dec!(2.0),
"January runs hot relative to the year: {}",
corrected.seasonal_factor
);
assert!(
corrected.projected_annual > uncorrected.projected_annual,
"the factor scales the projection"
);
}
#[test]
fn a_partial_prior_year_is_measured_over_its_own_span() {
let prior = aligned_days(date!(2025 - 01 - 01), 180, dec!(1));
let observed = aligned_days(date!(2026 - 02 - 01), 14, dec!(1));
let f = project_annual_consumption(&observed, Some(&prior)).unwrap();
assert_eq!(
f.seasonal_factor,
Decimal::ONE,
"a flat reference is seasonally neutral over any span"
);
assert!(
f.seasonal_correction_applied,
"the correction ran and found the window neutral — which is not the \
same as having no reference to run it against"
);
}
#[test]
fn no_prior_overlap_leaves_the_projection_uncorrected() {
let prior = aligned_days(date!(2025 - 08 - 01), 30, dec!(5));
let observed = aligned_days(date!(2026 - 01 - 05), 14, dec!(1));
let f = project_annual_consumption(&observed, Some(&prior)).unwrap();
assert!(!f.seasonal_correction_applied);
assert_eq!(f.seasonal_factor, Decimal::ONE);
}
#[test]
fn non_billable_intervals_do_not_contribute() {
let mut intervals = aligned_days(date!(2026 - 01 - 01), 14, dec!(1));
for iv in intervals.iter_mut().take(96) {
iv.quality = QualityFlag::Faulty;
}
let f = project_annual_consumption(&intervals, None).unwrap();
assert_eq!(
f.observed,
dec!(1) * Decimal::from(13 * 96),
"the faulty day is excluded from the sum"
);
assert_eq!(f.observed_days, 14, "but the window is still fourteen days");
}
#[test]
fn the_note_says_what_the_interval_does_not_model() {
let note = AnnualForecast::prediction_interval_note();
assert!(note.contains("autocorrelated"), "{note}");
}
}