pub mod classification;
pub mod substitute;
pub mod validation;
use rust_decimal::{Decimal, dec};
use time::{Date, OffsetDateTime, Weekday};
use crate::series::Series;
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::time::calendar::{DayBoundary, Period, shift_back_days, to_berlin};
use crate::time::holiday::Bundesland;
use crate::time::resolution::Resolution;
pub(crate) fn slots(
boundary: DayBoundary,
resolution: Resolution,
from: OffsetDateTime,
to: OffsetDateTime,
) -> impl Iterator<Item = Period> {
let mut next = boundary.bucket(from, resolution);
std::iter::from_fn(move || {
let current = next.take()?;
if current.start() >= to {
return None;
}
next = boundary.bucket(current.end(), resolution);
Some(current)
})
}
pub(crate) const fn is_actual(quality: QualityFlag) -> bool {
matches!(quality, QualityFlag::Measured | QualityFlag::Corrected)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DayClass {
Werktag,
Samstag,
SonnFeiertag,
}
fn day_class(date: Date, land: Bundesland) -> DayClass {
if date.weekday() == Weekday::Sunday || land.is_holiday(date) {
DayClass::SonnFeiertag
} else if date.weekday() == Weekday::Saturday {
DayClass::Samstag
} else {
DayClass::Werktag
}
}
pub(crate) struct Data<'a> {
pub(crate) series: &'a Series,
pub(crate) prior: Option<&'a Series>,
}
impl Data<'_> {
pub(crate) fn get(&self, from: OffsetDateTime) -> Option<&MeterInterval> {
self.series
.get(from)
.or_else(|| self.prior.and_then(|p| p.get(from)))
}
fn earliest(&self) -> Option<OffsetDateTime> {
let a = self.series.span().map(|(f, _)| f);
let b = self.prior.and_then(Series::span).map(|(f, _)| f);
match (a, b) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
}
pub(crate) fn references(
data: &Data<'_>,
slot: OffsetDateTime,
before: OffsetDateTime,
land: Bundesland,
n: usize,
usable: impl Fn(&MeterInterval) -> bool,
) -> Vec<(OffsetDateTime, Decimal)> {
let mut out = Vec::new();
let boundary = data.series.boundary();
let (Some(earliest), Some(day)) = (data.earliest(), boundary.day_of(slot)) else {
return out;
};
let class = day_class(day, land);
let wall = to_berlin(slot).time();
let mut back = 1;
while out.len() < n {
let Some(candidate) = shift_back_days(slot, back) else {
break;
};
back += 1;
if candidate < earliest {
break;
}
if to_berlin(candidate).time() != wall {
continue;
}
let Some(iv) = data.get(candidate) else {
continue;
};
let same_class = boundary
.day_of(candidate)
.is_some_and(|d| day_class(d, land) == class);
if iv.to() <= before && same_class && usable(iv) {
out.push((candidate, iv.value()));
}
}
out
}
const K_MAD: Decimal = dec!(1.4826);
const K_MEAN_AD: Decimal = dec!(1.253314);
fn median(values: &mut [Decimal]) -> Option<Decimal> {
values.sort_unstable();
let mid = values.len() / 2;
if values.is_empty() {
None
} else if values.len().is_multiple_of(2) {
values[mid - 1]
.checked_add(values[mid])?
.checked_div(Decimal::TWO)
} else {
Some(values[mid])
}
}
pub(crate) fn hampel(
x: Decimal,
window: &mut [Decimal],
t: Decimal,
floor: Decimal,
) -> Option<Decimal> {
let centre = median(window)?;
let mut deviations: Vec<Decimal> = window
.iter()
.map(|v| v.checked_sub(centre).map(|d| d.abs()))
.collect::<Option<_>>()?;
let mad = median(&mut deviations)?;
let spread = if mad > Decimal::ZERO {
K_MAD.checked_mul(mad)?
} else {
let mut sum = Decimal::ZERO;
for d in &deviations {
sum = sum.checked_add(*d)?;
}
let n = Decimal::from(deviations.len());
K_MEAN_AD.checked_mul(sum.checked_div(n)?)?
};
let sigma = spread.max(floor);
let deviation = x.checked_sub(centre)?.abs();
let outlier = if sigma <= Decimal::ZERO {
deviation > Decimal::ZERO
} else {
deviation > t.checked_mul(sigma)?
};
outlier.then_some(centre)
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
#[test]
fn hampel_finds_the_spike_and_a_floor_quiets_a_flat_window() {
let mut w = [
dec!(1),
dec!(1.1),
dec!(1),
dec!(50),
dec!(1),
dec!(1.1),
dec!(1),
];
assert_eq!(hampel(dec!(50), &mut w, dec!(3), dec!(0)), Some(dec!(1)));
let mut flat = [dec!(1), dec!(1), dec!(1), dec!(1.1), dec!(1)];
assert!(hampel(dec!(1.1), &mut flat, dec!(3), dec!(0)).is_some());
assert!(hampel(dec!(1.1), &mut flat, dec!(3), dec!(0.1)).is_none());
}
#[test]
fn a_holiday_is_a_sunday() {
let d = date!(2026 - 06 - 04);
assert_eq!(day_class(d, Bundesland::By), DayClass::SonnFeiertag);
assert_eq!(day_class(d, Bundesland::Be), DayClass::Werktag);
assert_eq!(
day_class(date!(2026 - 06 - 06), Bundesland::Be),
DayClass::Samstag
);
}
#[test]
fn slots_walk_the_grid_across_a_long_day() {
let day = DayBoundary::Strom.day(date!(2026 - 10 - 25)).unwrap();
let n = slots(
DayBoundary::Strom,
Resolution::QUARTER_HOUR,
day.start(),
day.end(),
)
.count();
assert_eq!(n, 100);
let first = slots(
DayBoundary::Strom,
Resolution::Hour,
datetime!(2026-06-01 0:30 UTC),
datetime!(2026-06-01 2:00 UTC),
)
.next()
.unwrap();
assert_eq!(first.start(), datetime!(2026-06-01 0:00 UTC));
}
}