use rust_decimal::Decimal;
use time::{Date, OffsetDateTime};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::precision::{
FORECAST_ACCURACY_DP, FORECAST_ACCURACY_STRATEGY, FORECAST_DP, FORECAST_STRATEGY,
SEASONAL_FACTOR_DP, SEASONAL_FACTOR_STRATEGY,
};
use crate::series::Series;
use crate::series::interval::MeterInterval;
use crate::slp::strom::DynamicSlpProfile;
use crate::time::calendar::DayBoundary;
use crate::time::holiday::SlpCalendar;
use crate::time::resolution::Resolution;
pub const MIN_OBSERVATION_DAYS: u32 = 7;
pub const MIN_COVERAGE_PERCENT: u32 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ForecastError {
#[error("the series holds no billable interval")]
Empty,
#[error("resolution {0} cannot be projected")]
Resolution(Resolution),
#[error("the window spans {days} days; at least {MIN_OBSERVATION_DAYS} are needed")]
TooShort {
days: u32,
},
#[error("{billable} of {expected} slots are billable, below {MIN_COVERAGE_PERCENT} %")]
Coverage {
billable: u32,
expected: u32,
},
#[error("the prior year cannot support a seasonal factor")]
PriorYear,
#[error("the load profile has no value at {0}")]
ProfileMissing(OffsetDateTime),
#[error("the load profile's share of the observed slots is not positive")]
ProfileZero,
#[error("an instant lies outside the supported calendar years")]
Calendar,
#[error("the arithmetic overflows")]
Overflow,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct AnnualForecast {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date"))]
pub first_day: Date,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date"))]
pub last_day: Date,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub observed_kwh: Decimal,
pub observed_days: u32,
pub billable_slots: u32,
pub expected_slots: u32,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub daily_average_kwh: Decimal,
pub target_year_days: u16,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
pub seasonal_factor: Option<Decimal>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub projected_annual_kwh: Decimal,
}
pub fn project_annual_consumption(
series: &Series,
prior_year: Option<&Series>,
) -> Result<AnnualForecast, ForecastError> {
let window = Window::of(series)?;
if window.days < MIN_OBSERVATION_DAYS {
return Err(ForecastError::TooShort { days: window.days });
}
let observed = window.measure(series)?;
observed.require_coverage()?;
let daily_average = observed
.daily_rate()?
.round_dp_with_strategy(FORECAST_DP, FORECAST_STRATEGY);
let target_year = window.last_day.year();
let target_year_days = time::util::days_in_year(target_year);
let annual = daily_average
.checked_mul(Decimal::from(target_year_days))
.ok_or(ForecastError::Overflow)?;
let seasonal_factor = prior_year
.map(|prior| seasonal_factor(&window, prior))
.transpose()?;
let projected = match seasonal_factor {
Some(factor) => annual.checked_div(factor).ok_or(ForecastError::PriorYear)?,
None => annual,
};
Ok(AnnualForecast {
first_day: window.first_day,
last_day: window.last_day,
observed_kwh: observed.energy,
observed_days: window.days,
billable_slots: observed.billable,
expected_slots: observed.expected,
daily_average_kwh: daily_average,
target_year_days,
seasonal_factor,
projected_annual_kwh: projected.round_dp_with_strategy(FORECAST_DP, FORECAST_STRATEGY),
})
}
fn seasonal_factor(window: &Window, prior: &Series) -> Result<Decimal, ForecastError> {
let boundary = prior.boundary();
let shift = |d: Date| -> Result<Date, ForecastError> {
let start = boundary.day(d).ok_or(ForecastError::Calendar)?.start();
let back =
crate::time::calendar::shift_back_one_year(start).ok_or(ForecastError::Calendar)?;
boundary.day_of(back).ok_or(ForecastError::Calendar)
};
let shifted = Window::new(
boundary,
prior.resolution(),
shift(window.first_day)?,
shift(window.last_day)?,
)?;
let in_window = shifted.measure(prior)?;
in_window.require_coverage()?;
let reference = Window::of(prior)?.measure(prior)?;
let window_rate = in_window.daily_rate()?;
let reference_rate = reference.daily_rate()?;
if window_rate <= Decimal::ZERO || reference_rate <= Decimal::ZERO {
return Err(ForecastError::PriorYear);
}
Ok(window_rate
.checked_div(reference_rate)
.ok_or(ForecastError::Overflow)?
.round_dp_with_strategy(SEASONAL_FACTOR_DP, SEASONAL_FACTOR_STRATEGY))
}
struct Window {
boundary: DayBoundary,
resolution: Resolution,
first_day: Date,
last_day: Date,
days: u32,
}
struct Measured {
energy: Decimal,
billable: u32,
expected: u32,
days: u32,
}
impl Window {
fn new(
boundary: DayBoundary,
resolution: Resolution,
first_day: Date,
last_day: Date,
) -> Result<Self, ForecastError> {
if matches!(resolution, Resolution::Month | Resolution::Year) {
return Err(ForecastError::Resolution(resolution));
}
let days = u32::try_from((last_day - first_day).whole_days() + 1)
.map_err(|_| ForecastError::Calendar)?;
Ok(Self {
boundary,
resolution,
first_day,
last_day,
days,
})
}
fn of(series: &Series) -> Result<Self, ForecastError> {
let boundary = series.boundary();
let mut billable = series.iter().filter(|iv| iv.quality().is_billable());
let first = billable.next().ok_or(ForecastError::Empty)?;
let last = billable.next_back().unwrap_or(first);
let day = |iv: &MeterInterval| boundary.day_of(iv.from()).ok_or(ForecastError::Calendar);
Self::new(boundary, series.resolution(), day(first)?, day(last)?)
}
fn start(&self) -> Result<OffsetDateTime, ForecastError> {
Ok(self
.boundary
.day(self.first_day)
.ok_or(ForecastError::Calendar)?
.start())
}
fn end(&self) -> Result<OffsetDateTime, ForecastError> {
Ok(self
.boundary
.day(self.last_day)
.ok_or(ForecastError::Calendar)?
.end())
}
fn measure(&self, series: &Series) -> Result<Measured, ForecastError> {
let (start, end) = (self.start()?, self.end()?);
let mut expected = 0u32;
let mut day = self.first_day;
loop {
let slots = self
.boundary
.day(day)
.and_then(|p| p.count(self.resolution))
.ok_or(ForecastError::Calendar)?;
expected = expected.checked_add(slots).ok_or(ForecastError::Overflow)?;
if day == self.last_day {
break;
}
day = day.next_day().ok_or(ForecastError::Calendar)?;
}
let mut energy = Decimal::ZERO;
let mut billable = 0u32;
for iv in series
.iter()
.filter(|iv| iv.from() >= start && iv.to() <= end && iv.quality().is_billable())
{
energy = energy
.checked_add(iv.value())
.ok_or(ForecastError::Overflow)?;
billable += 1;
}
Ok(Measured {
energy,
billable,
expected,
days: self.days,
})
}
}
impl Measured {
fn require_coverage(&self) -> Result<(), ForecastError> {
let lhs = u64::from(self.billable) * 100;
let rhs = u64::from(self.expected) * u64::from(MIN_COVERAGE_PERCENT);
if self.billable == 0 || lhs < rhs {
return Err(ForecastError::Coverage {
billable: self.billable,
expected: self.expected,
});
}
Ok(())
}
fn daily_rate(&self) -> Result<Decimal, ForecastError> {
if self.billable == 0 {
return Err(ForecastError::Empty);
}
let numerator = self
.energy
.checked_mul(Decimal::from(self.expected))
.ok_or(ForecastError::Overflow)?;
let denominator = Decimal::from(u64::from(self.days) * u64::from(self.billable));
numerator
.checked_div(denominator)
.ok_or(ForecastError::Overflow)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct SlpProjection {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub observed_kwh: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub profile_observed: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub profile_year: Decimal,
pub target_year: i32,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub projected_annual_kwh: Decimal,
}
pub fn project_annual_slp(
series: &Series,
profile: &DynamicSlpProfile,
calendar: &SlpCalendar,
) -> Result<SlpProjection, ForecastError> {
let resolution = series.resolution();
let weighable = match resolution {
Resolution::Day => true,
r => r
.fixed_seconds()
.is_some_and(|s| s.is_multiple_of(QUARTER_HOUR_SECS)),
};
if !weighable {
return Err(ForecastError::Resolution(resolution));
}
let mut observed = Decimal::ZERO;
let mut profile_observed = Decimal::ZERO;
let mut last: Option<&MeterInterval> = None;
for iv in series.iter().filter(|iv| iv.quality().is_billable()) {
observed = observed
.checked_add(iv.value())
.ok_or(ForecastError::Overflow)?;
profile_observed = profile_observed
.checked_add(profile_sum(profile, calendar, iv.from(), iv.to())?)
.ok_or(ForecastError::Overflow)?;
last = Some(iv);
}
let last = last.ok_or(ForecastError::Empty)?;
if profile_observed <= Decimal::ZERO {
return Err(ForecastError::ProfileZero);
}
let target_year = DayBoundary::Strom
.day_of(last.from())
.ok_or(ForecastError::Calendar)?
.year();
let year = DayBoundary::Strom
.year(target_year)
.ok_or(ForecastError::Calendar)?;
let profile_year = profile_sum(profile, calendar, year.start(), year.end())?;
let projected = observed
.checked_mul(profile_year)
.and_then(|n| n.checked_div(profile_observed))
.ok_or(ForecastError::Overflow)?
.round_dp_with_strategy(FORECAST_DP, FORECAST_STRATEGY);
Ok(SlpProjection {
observed_kwh: observed,
profile_observed,
profile_year,
target_year,
projected_annual_kwh: projected,
})
}
const QUARTER_HOUR_SECS: u32 = 900;
fn profile_sum(
profile: &DynamicSlpProfile,
calendar: &SlpCalendar,
from: OffsetDateTime,
to: OffsetDateTime,
) -> Result<Decimal, ForecastError> {
let mut sum = Decimal::ZERO;
let mut at = from;
while at < to {
let v = profile
.value_at(at, calendar)
.ok_or(ForecastError::ProfileMissing(at))?;
sum = sum.checked_add(v).ok_or(ForecastError::Overflow)?;
at += time::Duration::seconds(i64::from(QUARTER_HOUR_SECS));
}
Ok(sum)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AccuracyError {
#[error("no billable actual slot has a forecast value")]
NoOverlap,
#[error("the actual values are all zero")]
ZeroActual,
#[error("the history holds no slot with a value one week earlier")]
NoBaseline,
#[error("the seasonal-naive baseline has zero error")]
ZeroBaseline,
#[error("the arithmetic overflows")]
Overflow,
}
fn errors(actual: &Series, forecast: &Series) -> Result<(Decimal, Decimal, u32), AccuracyError> {
let mut abs_error = Decimal::ZERO;
let mut abs_actual = Decimal::ZERO;
let mut n = 0u32;
for a in actual.iter().filter(|iv| iv.quality().is_billable()) {
let Some(f) = forecast.get(a.from()) else {
continue;
};
let e = a
.value()
.checked_sub(f.value())
.ok_or(AccuracyError::Overflow)?
.abs();
abs_error = abs_error.checked_add(e).ok_or(AccuracyError::Overflow)?;
abs_actual = abs_actual
.checked_add(a.value().abs())
.ok_or(AccuracyError::Overflow)?;
n += 1;
}
if n == 0 {
return Err(AccuracyError::NoOverlap);
}
Ok((abs_error, abs_actual, n))
}
pub fn wape(actual: &Series, forecast: &Series) -> Result<Decimal, AccuracyError> {
let (abs_error, abs_actual, _) = errors(actual, forecast)?;
if abs_actual.is_zero() {
return Err(AccuracyError::ZeroActual);
}
Ok(abs_error
.checked_div(abs_actual)
.ok_or(AccuracyError::Overflow)?
.round_dp_with_strategy(FORECAST_ACCURACY_DP, FORECAST_ACCURACY_STRATEGY))
}
pub fn mase(
history: &Series,
actual: &Series,
forecast: &Series,
) -> Result<Decimal, AccuracyError> {
let (abs_error, _, n) = errors(actual, forecast)?;
let mut naive = Decimal::ZERO;
let mut m = 0u32;
for y in history.iter().filter(|iv| iv.quality().is_billable()) {
let Some(week_ago) = crate::time::calendar::shift_back_days(y.from(), 7)
.and_then(|t| history.get(t))
.filter(|iv| iv.quality().is_billable())
else {
continue;
};
let e = y
.value()
.checked_sub(week_ago.value())
.ok_or(AccuracyError::Overflow)?
.abs();
naive = naive.checked_add(e).ok_or(AccuracyError::Overflow)?;
m += 1;
}
if m == 0 {
return Err(AccuracyError::NoBaseline);
}
if naive.is_zero() {
return Err(AccuracyError::ZeroBaseline);
}
abs_error
.checked_mul(Decimal::from(m))
.and_then(|num| {
naive
.checked_mul(Decimal::from(n))
.and_then(|den| num.checked_div(den))
})
.map(|q| q.round_dp_with_strategy(FORECAST_ACCURACY_DP, FORECAST_ACCURACY_STRATEGY))
.ok_or(AccuracyError::Overflow)
}
#[must_use]
pub fn annual_energy_error(forecast_kwh: Decimal, actual_kwh: Decimal) -> Option<Decimal> {
if actual_kwh.is_zero() {
return None;
}
Some(
forecast_kwh
.checked_sub(actual_kwh)?
.checked_div(actual_kwh)?
.round_dp_with_strategy(FORECAST_ACCURACY_DP, FORECAST_ACCURACY_STRATEGY),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::QualityFlag;
use rust_decimal::dec;
use time::{Duration, macros::date};
fn days_with(start: Date, days: i64, kwh: impl Fn(i64) -> Decimal) -> Vec<MeterInterval> {
let mut out = Vec::new();
for d in 0..days {
let day = DayBoundary::Strom
.day(start.checked_add(Duration::days(d)).unwrap())
.unwrap();
let n = day.count(Resolution::QUARTER_HOUR).unwrap();
for i in 0..i64::from(n) {
out.push(
MeterInterval::quarter_hour(
day.start() + Duration::minutes(15 * i),
kwh(d),
QualityFlag::Measured,
)
.unwrap(),
);
}
}
out
}
fn series(intervals: Vec<MeterInterval>) -> Series {
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, intervals).unwrap()
}
fn flat(start: Date, days: i64, kwh: Decimal) -> Series {
series(days_with(start, days, |_| kwh))
}
#[test]
fn a_flat_fortnight_projects_the_flat_year() {
let f =
project_annual_consumption(&flat(date!(2026 - 01 - 01), 14, dec!(1)), None).unwrap();
assert_eq!(f.observed_days, 14);
assert_eq!(f.daily_average_kwh, dec!(96));
assert_eq!(f.projected_annual_kwh, dec!(96) * dec!(365));
assert_eq!(f.seasonal_factor, None);
}
#[test]
fn short_or_empty_windows_are_refused_with_a_reason() {
let empty = Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, vec![]).unwrap();
assert_eq!(
project_annual_consumption(&empty, None),
Err(ForecastError::Empty)
);
assert_eq!(
project_annual_consumption(&flat(date!(2026 - 01 - 01), 6, dec!(1)), None),
Err(ForecastError::TooShort { days: 6 })
);
assert!(project_annual_consumption(&flat(date!(2026 - 01 - 01), 7, dec!(1)), None).is_ok());
}
#[test]
fn projection_scales_to_the_real_year_length() {
let common =
project_annual_consumption(&flat(date!(2026 - 01 - 01), 14, dec!(1)), None).unwrap();
let leap =
project_annual_consumption(&flat(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_kwh - common.projected_annual_kwh,
dec!(96)
);
}
#[test]
fn a_window_across_dst_counts_calendar_days_and_real_slots() {
let f =
project_annual_consumption(&flat(date!(2026 - 03 - 23), 14, dec!(1)), None).unwrap();
assert_eq!(f.observed_days, 14);
assert_eq!(f.expected_slots, 14 * 96 - 4);
assert_eq!(f.billable_slots, f.expected_slots);
assert_eq!(f.daily_average_kwh, dec!(95.714));
}
#[test]
fn a_heavy_window_is_corrected_down() {
let prior = series(days_with(date!(2025 - 01 - 01), 365, |d| {
if d < 31 { dec!(2) } else { dec!(1) }
}));
let observed = flat(date!(2026 - 01 - 05), 14, dec!(2));
let uncorrected = project_annual_consumption(&observed, None).unwrap();
let corrected = project_annual_consumption(&observed, Some(&prior)).unwrap();
let factor = corrected.seasonal_factor.unwrap();
assert!(factor > dec!(1.5) && factor < dec!(2.0), "{factor}");
assert!(
corrected.projected_annual_kwh < uncorrected.projected_annual_kwh,
"a heavy window corrects down: {} vs {}",
corrected.projected_annual_kwh,
uncorrected.projected_annual_kwh
);
assert_eq!(
corrected.projected_annual_kwh,
(corrected.daily_average_kwh * dec!(365) / factor)
.round_dp_with_strategy(FORECAST_DP, FORECAST_STRATEGY)
);
}
#[test]
fn a_seasonal_year_is_projected_within_a_few_percent() {
let level = |date: Date| -> Decimal {
[
dec!(1.6),
dec!(1.5),
dec!(1.3),
dec!(1.1),
dec!(0.9),
dec!(0.8),
dec!(0.7),
dec!(0.75),
dec!(0.9),
dec!(1.1),
dec!(1.35),
dec!(1.55),
][usize::from(u8::from(date.month())) - 1]
};
let year = |y: i32, days: i64| {
let start = Date::from_calendar_date(y, time::Month::January, 1).unwrap();
series(days_with(start, days, |d| {
level(start.checked_add(Duration::days(d)).unwrap())
}))
};
let prior = year(2025, 365);
let truth = year(2026, 365).billable_total().unwrap();
let january = year(2026, 31);
let f = project_annual_consumption(&january, Some(&prior)).unwrap();
let err = annual_energy_error(f.projected_annual_kwh, truth).unwrap();
assert!(err.abs() < dec!(0.02), "within 2 %: {err}");
let naive = project_annual_consumption(&january, None).unwrap();
let naive_err = annual_energy_error(naive.projected_annual_kwh, truth).unwrap();
assert!(naive_err > dec!(0.3), "{naive_err}");
}
#[test]
fn a_gap_lowers_the_coverage_not_the_rate() {
let mut slots = days_with(date!(2026 - 01 - 01), 14, |_| dec!(1));
slots.drain(96..192); let f = project_annual_consumption(&series(slots), None).unwrap();
assert_eq!(f.observed_days, 14);
assert_eq!(f.billable_slots, 13 * 96);
assert_eq!(f.daily_average_kwh, dec!(96), "the rate is unchanged");
let mut slots = days_with(date!(2026 - 01 - 01), 14, |_| dec!(1));
for iv in slots.iter_mut().take(96) {
*iv = iv.clone().with_quality(QualityFlag::Faulty);
}
let f = project_annual_consumption(&series(slots), None).unwrap();
assert_eq!(f.daily_average_kwh, dec!(96));
}
#[test]
fn coverage_below_the_threshold_is_refused() {
let mut slots = days_with(date!(2026 - 01 - 01), 10, |_| dec!(1));
slots.drain(100..197);
assert_eq!(
project_annual_consumption(&series(slots), None),
Err(ForecastError::Coverage {
billable: 863,
expected: 960
})
);
let mut slots = days_with(date!(2026 - 01 - 01), 10, |_| dec!(1));
slots.drain(100..196);
assert!(project_annual_consumption(&series(slots), None).is_ok());
}
#[test]
fn a_gap_in_the_prior_window_does_not_move_the_factor() {
let mut prior = days_with(date!(2025 - 01 - 01), 60, |_| dec!(1));
prior.drain(96..192);
let f = project_annual_consumption(
&flat(date!(2026 - 01 - 01), 14, dec!(1)),
Some(&series(prior)),
)
.unwrap();
assert_eq!(f.seasonal_factor, Some(Decimal::ONE));
}
#[test]
fn a_prior_year_that_misses_the_window_is_refused() {
let prior = flat(date!(2025 - 08 - 01), 30, dec!(5));
let observed = flat(date!(2026 - 01 - 05), 14, dec!(1));
assert!(matches!(
project_annual_consumption(&observed, Some(&prior)),
Err(ForecastError::Coverage { billable: 0, .. })
));
}
fn g25() -> (DynamicSlpProfile, SlpCalendar) {
use crate::slp::strom::{LoadProfile, SlpDayType};
let mut p = DynamicSlpProfile::new(LoadProfile::G25);
for month in 1u8..=12 {
let v = if (4..=9).contains(&month) {
dec!(1)
} else {
dec!(2)
};
for dt in SlpDayType::ALL {
p.insert(month, dt, vec![v; 96]).unwrap();
}
}
(p, SlpCalendar::new(crate::time::holiday::Bundesland::Be))
}
#[test]
fn the_slp_projection_scales_by_the_profile_share() {
let (profile, cal) = g25();
let january = flat(date!(2026 - 01 - 01), 31, dec!(2));
let p = project_annual_slp(&january, &profile, &cal).unwrap();
assert_eq!(p.projected_annual_kwh, p.profile_year);
assert_eq!(p.target_year, 2026);
let mut slots = days_with(date!(2026 - 01 - 01), 31, |_| dec!(2));
slots.drain(0..500);
let gappy = project_annual_slp(&series(slots), &profile, &cal).unwrap();
assert_eq!(gappy.projected_annual_kwh, p.projected_annual_kwh);
}
#[test]
fn the_slp_projection_refuses_a_missing_profile_value() {
use crate::slp::strom::LoadProfile;
let empty = DynamicSlpProfile::new(LoadProfile::G25);
let cal = SlpCalendar::new(crate::time::holiday::Bundesland::Be);
assert!(matches!(
project_annual_slp(&flat(date!(2026 - 01 - 01), 1, dec!(1)), &empty, &cal),
Err(ForecastError::ProfileMissing(_))
));
}
#[test]
fn wape_weights_absolute_errors_by_energy() {
let actual = series(days_with(date!(2026 - 01 - 01), 1, |_| dec!(2)));
let forecast = series(days_with(date!(2026 - 01 - 01), 1, |_| dec!(1.5)));
assert_eq!(wape(&actual, &forecast), Ok(dec!(0.25)));
let zero = flat(date!(2026 - 01 - 01), 1, dec!(0));
assert_eq!(wape(&zero, &forecast), Err(AccuracyError::ZeroActual));
let elsewhere = flat(date!(2026 - 02 - 01), 1, dec!(1));
assert_eq!(wape(&actual, &elsewhere), Err(AccuracyError::NoOverlap));
}
#[test]
fn mase_scales_by_the_seasonal_naive_error() {
let history = series(days_with(date!(2026 - 01 - 05), 21, |d| {
if (d / 7) % 2 == 0 { dec!(1) } else { dec!(2) }
}));
let actual = flat(date!(2026 - 01 - 26), 1, dec!(1));
let forecast = flat(date!(2026 - 01 - 26), 1, dec!(1.25));
assert_eq!(mase(&history, &actual, &forecast), Ok(dec!(0.25)));
let constant = flat(date!(2026 - 01 - 05), 14, dec!(1));
assert_eq!(
mase(&constant, &actual, &forecast),
Err(AccuracyError::ZeroBaseline)
);
let short = flat(date!(2026 - 01 - 05), 3, dec!(1));
assert_eq!(
mase(&short, &actual, &forecast),
Err(AccuracyError::NoBaseline)
);
}
#[test]
fn the_annual_energy_error_is_signed() {
assert_eq!(annual_energy_error(dec!(90), dec!(100)), Some(dec!(-0.1)));
assert_eq!(annual_energy_error(dec!(110), dec!(100)), Some(dec!(0.1)));
assert_eq!(annual_energy_error(dec!(1), Decimal::ZERO), None);
}
}