use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::{Duration, OffsetDateTime};
use time_tz::{OffsetDateTimeExt as _, timezones};
use crate::calendar::DayBoundary;
use crate::interval::{MeterInterval, QualityFlag};
use crate::resolution::IntervalResolution;
pub const REFERENCE_PERIOD_DAYS: i64 = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SubstituteMethod {
#[default]
LinearInterpolation,
PriorPeriodAverage,
ZeroFill,
LastValueCarryForward,
}
impl SubstituteMethod {
pub const ALL: [Self; 4] = [
Self::LinearInterpolation,
Self::PriorPeriodAverage,
Self::ZeroFill,
Self::LastValueCarryForward,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LinearInterpolation => "LINEAR_INTERPOLATION",
Self::PriorPeriodAverage => "PRIOR_PERIOD_AVERAGE",
Self::ZeroFill => "ZERO_FILL",
Self::LastValueCarryForward => "LAST_VALUE_CARRY_FORWARD",
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::LinearInterpolation => "Lineare Interpolation zwischen den Randwerten",
Self::PriorPeriodAverage => "Vorperiodenmittelwert, gleicher Zeitschlitz",
Self::ZeroFill => "Nullwert (dokumentierter Lieferstopp)",
Self::LastValueCarryForward => "Letzter plausibler Wert fortgeschrieben",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SubstitutionReason {
#[default]
NoMeasurementAvailable,
MeterFault,
GatewayCommFailure,
PlausibilityCheckFailed,
ManualCorrection,
MeterExchangeInterpolation,
Other,
}
impl SubstitutionReason {
pub const ALL: [Self; 7] = [
Self::NoMeasurementAvailable,
Self::MeterFault,
Self::GatewayCommFailure,
Self::PlausibilityCheckFailed,
Self::ManualCorrection,
Self::MeterExchangeInterpolation,
Self::Other,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NoMeasurementAvailable => "NO_MEASUREMENT_AVAILABLE",
Self::MeterFault => "METER_FAULT",
Self::GatewayCommFailure => "GATEWAY_COMM_FAILURE",
Self::PlausibilityCheckFailed => "PLAUSIBILITY_CHECK_FAILED",
Self::ManualCorrection => "MANUAL_CORRECTION",
Self::MeterExchangeInterpolation => "METER_EXCHANGE_INTERPOLATION",
Self::Other => "OTHER",
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::NoMeasurementAvailable => "Kein Messwert verfügbar",
Self::MeterFault => "Zählerdefekt",
Self::GatewayCommFailure => "SMGW-Kommunikationsfehler",
Self::PlausibilityCheckFailed => "Plausibilitätsprüfung fehlgeschlagen",
Self::ManualCorrection => "Manuelle Korrektur durch MSB/Betreiber",
Self::MeterExchangeInterpolation => "Zählerwechsel — Interpolation über Wechselgrenze",
Self::Other => "Sonstiger dokumentierter Grund",
}
}
}
crate::codes::string_codes! {
SubstituteMethod;
SubstitutionReason;
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SubstituteEntry {
pub interval: MeterInterval,
pub method: SubstituteMethod,
pub reason: SubstitutionReason,
pub reference_count: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FillGapsConfig {
pub resolution: IntervalResolution,
pub period: (OffsetDateTime, OffsetDateTime),
pub method: SubstituteMethod,
pub prior_period_intervals: Vec<MeterInterval>,
pub short_gap_threshold: usize,
pub day_boundary: DayBoundary,
pub reason: SubstitutionReason,
}
impl FillGapsConfig {
#[must_use]
pub fn new(resolution: IntervalResolution, from: OffsetDateTime, to: OffsetDateTime) -> Self {
Self {
resolution,
period: (from, to),
method: SubstituteMethod::default(),
prior_period_intervals: Vec::new(),
short_gap_threshold: 3,
day_boundary: DayBoundary::Midnight,
reason: SubstitutionReason::NoMeasurementAvailable,
}
}
#[must_use]
pub const fn on(mut self, boundary: DayBoundary) -> Self {
self.day_boundary = boundary;
self
}
#[must_use]
pub fn with_method(mut self, method: SubstituteMethod) -> Self {
self.method = method;
self
}
#[must_use]
pub fn prior_period(mut self, prior_period_intervals: Vec<MeterInterval>) -> Self {
self.method = SubstituteMethod::PriorPeriodAverage;
self.prior_period_intervals = prior_period_intervals;
self
}
#[must_use]
pub fn zero_fill(mut self) -> Self {
self.method = SubstituteMethod::ZeroFill;
self.short_gap_threshold = 0;
self
}
#[must_use]
pub fn short_gap_threshold(mut self, n: usize) -> Self {
self.short_gap_threshold = n;
self
}
#[must_use]
pub fn because(mut self, reason: SubstitutionReason) -> Self {
self.reason = reason;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FilledSeries {
pub intervals: Vec<MeterInterval>,
pub substitutions: Vec<SubstituteEntry>,
}
impl FilledSeries {
#[must_use]
pub fn substituted_count(&self) -> usize {
self.substitutions.len()
}
#[must_use]
pub fn measured_pct(&self) -> f64 {
if self.intervals.is_empty() {
return 0.0;
}
let measured = self.intervals.len() - self.substitutions.len();
measured as f64 / self.intervals.len() as f64 * 100.0
}
}
#[must_use]
pub fn fill_gaps(intervals: &[MeterInterval], config: &FillGapsConfig) -> FilledSeries {
let (from, to) = config.period;
let resolution = config.resolution;
if to <= from {
return FilledSeries {
intervals: Vec::new(),
substitutions: Vec::new(),
};
}
let measured: BTreeMap<i64, &MeterInterval> = intervals
.iter()
.map(|iv| (iv.from.unix_timestamp(), iv))
.collect();
let reference = PriorPeriodIndex::build(&config.prior_period_intervals);
let mut out: Vec<MeterInterval> = Vec::new();
let mut substitutions: Vec<SubstituteEntry> = Vec::new();
let mut anchor: Option<(usize, Decimal)> = None;
let mut gap: Option<Gap> = None;
let obis = intervals
.iter()
.min_by_key(|iv| iv.from)
.and_then(|iv| iv.obis_code);
let mut cursor = from;
let mut idx = 0usize;
while cursor < to {
let Some(next) = advance(cursor, resolution, config.day_boundary) else {
break;
};
let ts = cursor.unix_timestamp();
if let Some(&iv) = measured.get(&ts) {
out.push(iv.clone());
if iv.quality.is_billable() {
anchor = Some((idx, iv.value));
}
gap = None;
cursor = next;
idx += 1;
continue;
}
let current = match gap {
Some(ref g) => g.clone(),
None => {
let g = Gap::measure(
&measured,
cursor,
idx,
resolution,
config.day_boundary,
to,
anchor,
);
gap = Some(g.clone());
g
}
};
let effective = if current.run_len <= config.short_gap_threshold {
SubstituteMethod::LinearInterpolation
} else {
config.method
};
let (value, applied, reference_count) =
current.synthesise(effective, idx, cursor, &reference, anchor.map(|(_, v)| v));
let interval = MeterInterval {
from: cursor,
to: next,
value,
quality: QualityFlag::Substituted,
obis_code: obis,
};
out.push(interval.clone());
substitutions.push(SubstituteEntry {
interval,
method: applied,
reason: config.reason,
reference_count,
});
anchor = Some((idx, value));
cursor = next;
idx += 1;
}
FilledSeries {
intervals: out,
substitutions,
}
}
fn advance(
cursor: OffsetDateTime,
resolution: IntervalResolution,
boundary: DayBoundary,
) -> Option<OffsetDateTime> {
let next = match resolution {
IntervalResolution::Day => boundary.day_end_utc(boundary.local_day(cursor)),
IntervalResolution::Month => boundary.month_end_utc(boundary.local_day(cursor)),
IntervalResolution::Year => boundary.year_end_utc(boundary.local_year(cursor)),
fixed => cursor + Duration::seconds(i64::from(fixed.fixed_seconds()?)),
};
(next > cursor).then_some(next)
}
#[derive(Debug, Clone)]
struct Gap {
run_len: usize,
preceding: Option<(usize, Decimal)>,
following: Option<(usize, Decimal)>,
}
impl Gap {
#[allow(clippy::too_many_arguments)]
fn measure(
measured: &BTreeMap<i64, &MeterInterval>,
start: OffsetDateTime,
start_idx: usize,
resolution: IntervalResolution,
boundary: DayBoundary,
end: OffsetDateTime,
preceding: Option<(usize, Decimal)>,
) -> Self {
let mut run_len = 0usize;
let mut cursor = start;
while cursor < end && !measured.contains_key(&cursor.unix_timestamp()) {
run_len += 1;
let Some(next) = advance(cursor, resolution, boundary) else {
break;
};
cursor = next;
}
let closing = measured
.range(cursor.unix_timestamp()..)
.map(|(_, iv)| *iv)
.find(|iv| iv.quality.is_billable());
let following = closing.and_then(|iv| {
let target = iv.from.unix_timestamp();
let mut walk = cursor;
let mut walk_idx = start_idx + run_len;
while walk.unix_timestamp() < target {
walk = advance(walk, resolution, boundary)?;
walk_idx += 1;
}
Some((walk_idx, iv.value))
});
Self {
run_len,
preceding,
following,
}
}
fn synthesise(
&self,
requested: SubstituteMethod,
idx: usize,
cursor: OffsetDateTime,
reference: &PriorPeriodIndex,
last_value: Option<Decimal>,
) -> (Decimal, SubstituteMethod, u32) {
use SubstituteMethod as M;
let preceding = self.preceding.map(|(_, v)| v);
let following = self.following.map(|(_, v)| v);
match requested {
M::ZeroFill => (Decimal::ZERO, M::ZeroFill, 0),
M::LastValueCarryForward => match last_value.or(preceding).or(following) {
Some(v) => (v, M::LastValueCarryForward, 1),
None => (Decimal::ZERO, M::ZeroFill, 0),
},
M::PriorPeriodAverage => match reference.average_for(cursor) {
Some((avg, n)) => (avg, M::PriorPeriodAverage, n),
None => match last_value.or(preceding).or(following) {
Some(v) => (v, M::LastValueCarryForward, 1),
None => (Decimal::ZERO, M::ZeroFill, 0),
},
},
M::LinearInterpolation => match (self.preceding, self.following) {
(Some((pi, p)), Some((fi, f))) if pi < idx && idx < fi => {
let denom = Decimal::from((fi - pi) as u64);
let numer = Decimal::from((idx - pi) as u64);
(p + (f - p) * numer / denom, M::LinearInterpolation, 2)
}
(Some((_, p)), None) | (Some((_, p)), Some(_)) => (p, M::LastValueCarryForward, 1),
(None, Some((_, f))) => (f, M::LastValueCarryForward, 1),
(None, None) => (Decimal::ZERO, M::ZeroFill, 0),
},
}
}
}
struct PriorPeriodIndex {
slots: BTreeMap<(u8, u8, u8), Vec<(OffsetDateTime, Decimal)>>,
}
impl PriorPeriodIndex {
fn build(intervals: &[MeterInterval]) -> Self {
let mut slots: BTreeMap<(u8, u8, u8), Vec<(OffsetDateTime, Decimal)>> = BTreeMap::new();
for iv in intervals.iter().filter(|iv| iv.quality.is_billable()) {
slots
.entry(slot_key(iv.from))
.or_default()
.push((iv.from, iv.value));
}
Self { slots }
}
fn average_for(&self, target: OffsetDateTime) -> Option<(Decimal, u32)> {
let window_start = crate::calendar::shift_back_days(target, REFERENCE_PERIOD_DAYS);
let samples = self.slots.get(&slot_key(target))?;
let matching: Vec<Decimal> = samples
.iter()
.filter(|(at, _)| *at >= window_start && *at < target)
.map(|(_, v)| *v)
.collect();
let n = u32::try_from(matching.len()).ok()?;
if n == 0 {
return None;
}
Some((matching.iter().sum::<Decimal>() / Decimal::from(n), n))
}
}
fn slot_key(ts: OffsetDateTime) -> (u8, u8, u8) {
let local = ts.to_timezone(timezones::db::europe::BERLIN);
(
local.weekday().number_days_from_monday(),
local.hour(),
local.minute(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::datetime;
const BASE: OffsetDateTime = datetime!(2026-01-01 0:00 UTC);
fn iv_at(from: OffsetDateTime, kwh: Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + Duration::minutes(15),
value: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
fn iv(offset_min: i64, kwh: Decimal) -> MeterInterval {
iv_at(BASE + Duration::minutes(offset_min), kwh)
}
fn cfg(from_min: i64, to_min: i64) -> FillGapsConfig {
FillGapsConfig::new(
IntervalResolution::QuarterHour,
BASE + Duration::minutes(from_min),
BASE + Duration::minutes(to_min),
)
}
#[test]
fn a_clean_series_is_returned_untouched() {
let intervals = vec![iv(0, dec!(2.0)), iv(15, dec!(2.1)), iv(30, dec!(2.2))];
let filled = fill_gaps(&intervals, &cfg(0, 45));
assert_eq!(filled.intervals.len(), 3);
assert!(filled.substitutions.is_empty());
assert!(
filled
.intervals
.iter()
.all(|iv| iv.quality == QualityFlag::Measured)
);
assert!((filled.measured_pct() - 100.0).abs() < 1e-9);
}
#[test]
fn interpolation_uses_interior_fractions() {
let intervals = vec![iv(0, dec!(0)), iv(60, dec!(100))];
let filled = fill_gaps(&intervals, &cfg(0, 75).short_gap_threshold(10));
let values: Vec<Decimal> = filled.intervals.iter().map(|iv| iv.value).collect();
assert_eq!(
values,
vec![dec!(0), dec!(25), dec!(50), dec!(75), dec!(100)]
);
for entry in &filled.substitutions {
assert!(entry.interval.value > dec!(0) && entry.interval.value < dec!(100));
assert_eq!(entry.method, SubstituteMethod::LinearInterpolation);
assert_eq!(entry.reference_count, 2);
}
}
#[test]
fn a_single_gap_is_the_midpoint() {
let filled = fill_gaps(&[iv(0, dec!(2.0)), iv(30, dec!(2.4))], &cfg(0, 45));
assert_eq!(filled.intervals.len(), 3);
assert_eq!(filled.intervals[1].value, dec!(2.2));
assert_eq!(filled.intervals[1].quality, QualityFlag::Substituted);
}
#[test]
fn interpolation_is_symmetric() {
let rising = fill_gaps(
&[iv(0, dec!(0)), iv(60, dec!(100))],
&cfg(0, 75).short_gap_threshold(10),
);
let falling = fill_gaps(
&[iv(0, dec!(100)), iv(60, dec!(0))],
&cfg(0, 75).short_gap_threshold(10),
);
for (a, b) in rising.intervals.iter().zip(falling.intervals.iter()) {
assert_eq!(a.value + b.value, dec!(100), "at {}", a.from);
}
}
#[test]
fn a_long_gap_keeps_its_method_to_the_last_interval() {
let filled = fill_gaps(
&[iv(0, dec!(10)), iv(120, dec!(20))],
&cfg(0, 135)
.with_method(SubstituteMethod::ZeroFill)
.short_gap_threshold(2),
);
assert_eq!(filled.substituted_count(), 7, "seven quarter-hours missing");
for entry in &filled.substitutions {
assert_eq!(
entry.interval.value,
dec!(0),
"every slot of a 7-interval gap uses the configured ZeroFill, \
including the last two — got {} at {}",
entry.interval.value,
entry.interval.from
);
assert_eq!(entry.method, SubstituteMethod::ZeroFill);
}
}
#[test]
fn short_gaps_are_interpolated_whatever_the_method() {
let filled = fill_gaps(
&[iv(0, dec!(2.0)), iv(30, dec!(4.0))],
&cfg(0, 45)
.with_method(SubstituteMethod::ZeroFill)
.short_gap_threshold(3),
);
assert_eq!(filled.intervals[1].value, dec!(3.0));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::LinearInterpolation
);
}
#[test]
fn zero_fill_applies_from_the_first_interval() {
let filled = fill_gaps(
&[iv(0, dec!(2.0)), iv(30, dec!(2.0))],
&cfg(0, 45).zero_fill(),
);
assert_eq!(filled.intervals[1].value, dec!(0));
assert_eq!(filled.substitutions[0].method, SubstituteMethod::ZeroFill);
}
#[test]
fn carry_forward_fills_a_trailing_gap() {
let filled = fill_gaps(
&[iv(0, dec!(3.0))],
&cfg(0, 30)
.with_method(SubstituteMethod::LastValueCarryForward)
.short_gap_threshold(0),
);
assert_eq!(filled.intervals.len(), 2);
assert_eq!(filled.intervals[1].value, dec!(3.0));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::LastValueCarryForward
);
assert_eq!(filled.substitutions[0].reference_count, 1);
}
#[test]
fn a_leading_gap_carries_the_first_value_back() {
let filled = fill_gaps(&[iv(30, dec!(5.0))], &cfg(0, 45));
assert_eq!(filled.intervals.len(), 3);
assert_eq!(filled.intervals[0].value, dec!(5.0));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::LastValueCarryForward,
"the audit record must name the fallback that ran, not the request"
);
}
#[test]
fn no_data_at_all_yields_zeros_with_no_references() {
let filled = fill_gaps(&[], &cfg(0, 30));
assert_eq!(filled.intervals.len(), 2);
assert!(filled.intervals.iter().all(|iv| iv.value.is_zero()));
for entry in &filled.substitutions {
assert_eq!(entry.method, SubstituteMethod::ZeroFill);
assert_eq!(entry.reference_count, 0);
}
assert!((filled.measured_pct() - 0.0).abs() < 1e-9);
}
#[test]
fn prior_period_average_uses_the_matching_slot() {
let prior = vec![iv_at(datetime!(2025-12-25 0:15 UTC), dec!(3.0))];
let filled = fill_gaps(
&[iv(0, dec!(2.0)), iv(30, dec!(4.0))],
&cfg(0, 45).prior_period(prior).short_gap_threshold(0),
);
assert_eq!(filled.intervals[1].value, dec!(3.0));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::PriorPeriodAverage
);
assert_eq!(filled.substitutions[0].reference_count, 1);
}
#[test]
fn prior_period_average_falls_back_to_carry_forward() {
let prior = vec![iv_at(datetime!(2025-12-25 1:00 UTC), dec!(5.0))];
let filled = fill_gaps(
&[iv(0, dec!(2.5)), iv(30, dec!(4.0))],
&cfg(0, 45).prior_period(prior).short_gap_threshold(0),
);
assert_eq!(filled.intervals[1].value, dec!(2.5));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::LastValueCarryForward
);
}
#[test]
fn prior_period_average_distinguishes_weekdays_from_weekends() {
let mut prior: Vec<MeterInterval> = (23..=27)
.map(|day| {
iv_at(
datetime!(2026-02-01 08:00 UTC).replace_day(day).unwrap(),
dec!(100),
)
})
.collect();
prior.push(iv_at(datetime!(2026-02-22 08:00 UTC), dec!(4)));
let gap = datetime!(2026-03-01 08:00 UTC);
let filled = fill_gaps(
&[],
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
gap,
gap + Duration::minutes(15),
)
.prior_period(prior)
.short_gap_threshold(0),
);
assert_eq!(
filled.intervals[0].value,
dec!(4),
"a Sunday gap takes the prior Sunday's value, not the working-week average"
);
}
#[test]
fn only_the_preceding_week_feeds_the_average() {
let gap = datetime!(2026-03-09 08:00 UTC); let prior = vec![
iv_at(datetime!(2026-03-02 08:00 UTC), dec!(10)), iv_at(datetime!(2026-02-16 08:00 UTC), dec!(1000)), ];
let filled = fill_gaps(
&[],
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
gap,
gap + Duration::minutes(15),
)
.prior_period(prior)
.short_gap_threshold(0),
);
assert_eq!(
filled.intervals[0].value,
dec!(10),
"averaging in the older week would give 505"
);
}
#[test]
fn prior_period_average_survives_the_fall_back_week() {
let gap_start = datetime!(2026-10-28 11:00 UTC);
let prior = vec![iv_at(datetime!(2026-10-21 10:00 UTC), dec!(7.5))];
let filled = fill_gaps(
&[],
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
gap_start,
gap_start + Duration::minutes(15),
)
.prior_period(prior)
.short_gap_threshold(0),
);
assert_eq!(filled.intervals[0].value, dec!(7.5));
assert_eq!(
filled.substitutions[0].method,
SubstituteMethod::PriorPeriodAverage,
"the matching slot is one local week back and must be found, \
not silently replaced by carry-forward"
);
assert_eq!(filled.substitutions[0].reference_count, 1);
}
#[test]
fn the_fall_back_window_does_not_overreach() {
let gap_start = datetime!(2026-10-29 11:00 UTC); let prior = vec![iv_at(datetime!(2026-10-15 10:00 UTC), dec!(999))];
let filled = fill_gaps(
&[],
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
gap_start,
gap_start + Duration::minutes(15),
)
.prior_period(prior)
.short_gap_threshold(0),
);
assert_ne!(
filled.substitutions[0].method,
SubstituteMethod::PriorPeriodAverage,
"a fortnight-old sample is not in the reference week"
);
}
#[test]
fn interpolation_spans_to_the_billable_closing_value() {
let mut faulty = iv(60, dec!(999));
faulty.quality = QualityFlag::Faulty;
let series = vec![iv(0, dec!(0)), faulty, iv(75, dec!(100))];
let filled = fill_gaps(&series, &cfg(0, 90).short_gap_threshold(10));
let values: Vec<Decimal> = filled.intervals.iter().map(|iv| iv.value).collect();
assert_eq!(
values,
vec![dec!(0), dec!(20), dec!(40), dec!(60), dec!(999), dec!(100)]
);
assert_eq!(
filled.substituted_count(),
3,
"the faulty slot is passed through, never substituted"
);
assert!(
filled.intervals[4].quality == QualityFlag::Faulty,
"…and keeps its quality"
);
}
#[test]
fn interpolation_anchors_on_the_billable_preceding_value() {
let mut faulty = iv(15, dec!(999));
faulty.quality = QualityFlag::Faulty;
let series = vec![iv(0, dec!(0)), faulty, iv(60, dec!(100))];
let filled = fill_gaps(&series, &cfg(0, 75).short_gap_threshold(10));
let values: Vec<Decimal> = filled.intervals.iter().map(|iv| iv.value).collect();
assert_eq!(
values,
vec![dec!(0), dec!(999), dec!(50), dec!(75), dec!(100)]
);
assert_eq!(filled.substituted_count(), 2);
}
#[test]
fn runs_split_by_a_faulty_slot_share_one_line() {
let mut faulty = iv(30, dec!(999));
faulty.quality = QualityFlag::Faulty;
let series = vec![iv(0, dec!(0)), faulty, iv(75, dec!(100))];
let filled = fill_gaps(&series, &cfg(0, 90).short_gap_threshold(10));
let values: Vec<Decimal> = filled.intervals.iter().map(|iv| iv.value).collect();
assert_eq!(
values,
vec![dec!(0), dec!(20), dec!(999), dec!(60), dec!(80), dec!(100)]
);
}
#[test]
fn faulty_reference_values_are_excluded() {
let mut faulty = iv_at(datetime!(2025-12-25 0:15 UTC), dec!(999));
faulty.quality = QualityFlag::Faulty;
let good = iv_at(datetime!(2025-12-25 0:15 UTC), dec!(3.0));
let filled = fill_gaps(
&[iv(0, dec!(2.0))],
&cfg(15, 30)
.prior_period(vec![faulty, good])
.short_gap_threshold(0),
);
assert_eq!(filled.intervals[0].value, dec!(3.0));
}
#[test]
fn every_substitute_carries_its_reason_and_flag() {
let filled = fill_gaps(
&[iv(0, dec!(2.0)), iv(45, dec!(2.0))],
&cfg(0, 60).because(SubstitutionReason::GatewayCommFailure),
);
assert_eq!(filled.substituted_count(), 2);
for entry in &filled.substitutions {
assert_eq!(entry.reason, SubstitutionReason::GatewayCommFailure);
assert_eq!(entry.interval.quality, QualityFlag::Substituted);
assert!(!entry.reason.description().is_empty());
assert!(!entry.method.description().is_empty());
}
assert!((filled.measured_pct() - 50.0).abs() < 1e-9);
}
#[test]
fn the_audit_trail_matches_the_series() {
let filled = fill_gaps(&[iv(0, dec!(2.0)), iv(60, dec!(6.0))], &cfg(0, 75));
let substituted: Vec<&MeterInterval> = filled
.intervals
.iter()
.filter(|iv| iv.quality == QualityFlag::Substituted)
.collect();
assert_eq!(substituted.len(), filled.substitutions.len());
for (iv, entry) in substituted.iter().zip(&filled.substitutions) {
assert_eq!(**iv, entry.interval);
}
}
#[test]
fn substitutes_inherit_the_obis_channel() {
let mut first = iv(0, dec!(2.0));
first.obis_code = Some(crate::ObisCode::STROM_BEZUG_LASTGANG);
let filled = fill_gaps(&[first], &cfg(0, 30));
assert_eq!(
filled.intervals[1].obis_code,
Some(crate::ObisCode::STROM_BEZUG_LASTGANG)
);
}
#[test]
fn a_daily_fill_follows_the_calendar_across_dst() {
use crate::calendar;
use time::macros::date;
let days: Vec<time::Date> = (0..14)
.map(|i| {
date!(2026 - 03 - 23)
.checked_add(Duration::days(i))
.unwrap()
})
.collect();
let mut series: Vec<MeterInterval> = days
.iter()
.map(|&day| MeterInterval {
from: calendar::day_start_utc(day),
to: calendar::day_end_utc(day),
value: dec!(100),
quality: QualityFlag::Measured,
obis_code: None,
})
.collect();
let dropped = series.remove(10);
let period = (
calendar::day_start_utc(days[0]),
calendar::day_end_utc(*days.last().unwrap()),
);
let filled = fill_gaps(
&series,
&FillGapsConfig::new(IntervalResolution::Day, period.0, period.1),
);
assert_eq!(filled.intervals.len(), 14, "one slot per calendar day");
assert_eq!(
filled.substituted_count(),
1,
"exactly the dropped day is synthesised, not everything after the \
transition: {:?}",
filled
.substitutions
.iter()
.map(|e| e.interval.from)
.collect::<Vec<_>>()
);
assert_eq!(filled.substitutions[0].interval.from, dropped.from);
assert_eq!(filled.substitutions[0].interval.to, dropped.to);
let short = filled
.intervals
.iter()
.find(|iv| calendar::local_day(iv.from) == date!(2026 - 03 - 29))
.expect("the spring-forward day is in the range");
assert_eq!((short.to - short.from).whole_hours(), 23);
let fixed = fill_gaps(
&series,
&FillGapsConfig::new(
IntervalResolution::from_seconds(86_400).unwrap(),
period.0,
period.1,
),
);
assert!(
fixed.substituted_count() > 5,
"a fixed 24-hour grid loses every day after the transition, \
substituting {} of them",
fixed.substituted_count()
);
}
#[test]
fn degenerate_parameters_do_not_loop_or_panic() {
let intervals = vec![iv(0, dec!(2.0))];
let inverted = fill_gaps(
&intervals,
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
BASE + Duration::hours(1),
BASE,
),
);
assert!(inverted.intervals.is_empty());
assert!(inverted.substitutions.is_empty());
let empty = fill_gaps(
&intervals,
&FillGapsConfig::new(IntervalResolution::QuarterHour, BASE, BASE),
);
assert!(empty.intervals.is_empty());
}
#[test]
fn a_gap_longer_than_a_hundred_intervals_is_measured_in_full() {
let intervals = vec![
iv_at(BASE, dec!(10)),
iv_at(BASE + Duration::days(2), dec!(10)),
];
let filled = fill_gaps(
&intervals,
&FillGapsConfig::new(
IntervalResolution::QuarterHour,
BASE,
BASE + Duration::days(2) + Duration::minutes(15),
)
.with_method(SubstituteMethod::ZeroFill)
.short_gap_threshold(3),
);
assert_eq!(filled.substituted_count(), 191);
assert!(
filled
.substitutions
.iter()
.all(|e| e.method == SubstituteMethod::ZeroFill),
"a 191-interval gap is not a short gap at any point in it"
);
}
#[test]
fn enum_metadata_is_complete() {
for m in SubstituteMethod::ALL {
assert!(!m.description().is_empty(), "{m:?}");
}
for r in SubstitutionReason::ALL {
assert!(!r.description().is_empty(), "{r:?}");
}
assert_eq!(SubstituteMethod::ALL.len(), 4);
assert_eq!(SubstitutionReason::ALL.len(), 7);
}
}
#[cfg(test)]
mod gas_day_grid_tests {
use super::*;
use crate::calendar;
use rust_decimal::dec;
use time::macros::date;
#[test]
fn a_daily_gas_fill_walks_gas_days() {
let from = calendar::gas_day_start_utc(date!(2026 - 10 - 23));
let to = calendar::gas_day_start_utc(date!(2026 - 10 - 27));
let measured = vec![MeterInterval {
from,
to: calendar::gas_day_end_utc(date!(2026 - 10 - 23)),
value: dec!(500),
quality: QualityFlag::Measured,
obis_code: None,
}];
let filled = fill_gaps(
&measured,
&FillGapsConfig::new(IntervalResolution::Day, from, to)
.on(DayBoundary::Gastag)
.with_method(SubstituteMethod::LastValueCarryForward)
.short_gap_threshold(0),
);
assert_eq!(filled.intervals.len(), 4, "four Gastage");
for (i, day) in [
date!(2026 - 10 - 23),
date!(2026 - 10 - 24),
date!(2026 - 10 - 25),
date!(2026 - 10 - 26),
]
.into_iter()
.enumerate()
{
assert_eq!(
filled.intervals[i].from,
calendar::gas_day_start_utc(day),
"{day}"
);
assert_eq!(
filled.intervals[i].to,
calendar::gas_day_end_utc(day),
"{day}"
);
}
let saturday = &filled.intervals[1];
assert_eq!((saturday.to - saturday.from).whole_hours(), 25);
assert_eq!(filled.substituted_count(), 3);
}
#[test]
fn the_default_boundary_is_still_midnight() {
let cfg = FillGapsConfig::new(
IntervalResolution::Day,
calendar::day_start_utc(date!(2026 - 06 - 01)),
calendar::day_start_utc(date!(2026 - 06 - 03)),
);
assert_eq!(cfg.day_boundary, DayBoundary::Midnight);
let filled = fill_gaps(&[], &cfg);
assert_eq!(filled.intervals.len(), 2);
assert_eq!(
filled.intervals[0].from,
calendar::day_start_utc(date!(2026 - 06 - 01))
);
}
}