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, Sparte};
use crate::resolution::IntervalResolution;
pub const REFERENCE_PERIOD_DAYS: i64 = 7;
pub const SUBSTITUTE_DP: u32 = 6;
#[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 market_code(self, sparte: Sparte) -> Option<&'static str> {
match (self, sparte) {
(Self::LinearInterpolation, _) => Some("Z92"),
(Self::PriorPeriodAverage, Sparte::Strom) => Some("ZJ2"),
(Self::PriorPeriodAverage, _) => Some("Z95"),
(Self::LastValueCarryForward, Sparte::Strom) => None,
(Self::LastValueCarryForward, _) => Some("Z93"),
(Self::ZeroFill, _) => None,
}
}
#[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]
NoAccess,
CommunicationFailure,
GridOutage,
VoltageFailure,
DeviceExchange,
Calibration,
OutsideOperatingConditions,
MeteringEquipmentFault,
MeasurementUncertain,
FaultRegisterUsed,
ConversionIncomplete,
ClockAdjusted,
ImplausibleValue,
WrongTransformerRatio,
FaultyReading,
CalculationChanged,
MeteringPointRebuilt,
DataProcessingError,
MeteringEquipmentDefect,
TariffTimesChanged,
TariffSwitchDeviceDefect,
InsufficientPulseWeight,
MaintenanceCalibratedDevice,
DeviceReportsDisturbedValues,
MaintenanceConformantDevice,
ConsistencyCheckFailed,
StatedPerMeteringPoint,
RetrospectiveRequest,
}
impl SubstitutionReason {
pub const ALL: [Self; 28] = [
Self::NoAccess,
Self::CommunicationFailure,
Self::GridOutage,
Self::VoltageFailure,
Self::DeviceExchange,
Self::Calibration,
Self::OutsideOperatingConditions,
Self::MeteringEquipmentFault,
Self::MeasurementUncertain,
Self::FaultRegisterUsed,
Self::ConversionIncomplete,
Self::ClockAdjusted,
Self::ImplausibleValue,
Self::WrongTransformerRatio,
Self::FaultyReading,
Self::CalculationChanged,
Self::MeteringPointRebuilt,
Self::DataProcessingError,
Self::MeteringEquipmentDefect,
Self::TariffTimesChanged,
Self::TariffSwitchDeviceDefect,
Self::InsufficientPulseWeight,
Self::MaintenanceCalibratedDevice,
Self::DeviceReportsDisturbedValues,
Self::MaintenanceConformantDevice,
Self::ConsistencyCheckFailed,
Self::StatedPerMeteringPoint,
Self::RetrospectiveRequest,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NoAccess => "NO_ACCESS",
Self::CommunicationFailure => "COMMUNICATION_FAILURE",
Self::GridOutage => "GRID_OUTAGE",
Self::VoltageFailure => "VOLTAGE_FAILURE",
Self::DeviceExchange => "DEVICE_EXCHANGE",
Self::Calibration => "CALIBRATION",
Self::OutsideOperatingConditions => "OUTSIDE_OPERATING_CONDITIONS",
Self::MeteringEquipmentFault => "METERING_EQUIPMENT_FAULT",
Self::MeasurementUncertain => "MEASUREMENT_UNCERTAIN",
Self::FaultRegisterUsed => "FAULT_REGISTER_USED",
Self::ConversionIncomplete => "CONVERSION_INCOMPLETE",
Self::ClockAdjusted => "CLOCK_ADJUSTED",
Self::ImplausibleValue => "IMPLAUSIBLE_VALUE",
Self::WrongTransformerRatio => "WRONG_TRANSFORMER_RATIO",
Self::FaultyReading => "FAULTY_READING",
Self::CalculationChanged => "CALCULATION_CHANGED",
Self::MeteringPointRebuilt => "METERING_POINT_REBUILT",
Self::DataProcessingError => "DATA_PROCESSING_ERROR",
Self::MeteringEquipmentDefect => "METERING_EQUIPMENT_DEFECT",
Self::TariffTimesChanged => "TARIFF_TIMES_CHANGED",
Self::TariffSwitchDeviceDefect => "TARIFF_SWITCH_DEVICE_DEFECT",
Self::InsufficientPulseWeight => "INSUFFICIENT_PULSE_WEIGHT",
Self::MaintenanceCalibratedDevice => "MAINTENANCE_CALIBRATED_DEVICE",
Self::DeviceReportsDisturbedValues => "DEVICE_REPORTS_DISTURBED_VALUES",
Self::MaintenanceConformantDevice => "MAINTENANCE_CONFORMANT_DEVICE",
Self::ConsistencyCheckFailed => "CONSISTENCY_CHECK_FAILED",
Self::StatedPerMeteringPoint => "STATED_PER_METERING_POINT",
Self::RetrospectiveRequest => "RETROSPECTIVE_REQUEST",
}
}
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::NoAccess => "Z74",
Self::CommunicationFailure => "Z75",
Self::GridOutage => "Z76",
Self::VoltageFailure => "Z77",
Self::DeviceExchange => "Z78",
Self::Calibration => "Z79",
Self::OutsideOperatingConditions => "Z80",
Self::MeteringEquipmentFault => "Z81",
Self::MeasurementUncertain => "Z82",
Self::FaultRegisterUsed => "Z98",
Self::ConversionIncomplete => "Z99",
Self::ClockAdjusted => "ZA0",
Self::ImplausibleValue => "ZA1",
Self::WrongTransformerRatio => "ZA3",
Self::FaultyReading => "ZA4",
Self::CalculationChanged => "ZA5",
Self::MeteringPointRebuilt => "ZA6",
Self::DataProcessingError => "ZA7",
Self::MeteringEquipmentDefect => "ZB0",
Self::TariffTimesChanged => "ZB9",
Self::TariffSwitchDeviceDefect => "ZC2",
Self::InsufficientPulseWeight => "ZC4",
Self::MaintenanceCalibratedDevice => "ZR1",
Self::DeviceReportsDisturbedValues => "ZR2",
Self::MaintenanceConformantDevice => "ZR3",
Self::ConsistencyCheckFailed => "ZR4",
Self::StatedPerMeteringPoint => "ZS9",
Self::RetrospectiveRequest => "ZT8",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
let upper = code.trim().to_uppercase();
Self::ALL.into_iter().find(|r| r.code() == upper)
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::NoAccess => "kein Zugang",
Self::CommunicationFailure => "Kommunikationsstörung",
Self::GridOutage => "Netzausfall",
Self::VoltageFailure => "Spannungsausfall",
Self::DeviceExchange => "Gerätewechsel",
Self::Calibration => "Kalibrierung",
Self::OutsideOperatingConditions => "Gerät arbeitet außerhalb der Betriebsbedingungen",
Self::MeteringEquipmentFault => "Messeinrichtung gestört/defekt",
Self::MeasurementUncertain => "Unsicherheit Messung",
Self::FaultRegisterUsed => "Berücksichtigung Störmengenzählwerk",
Self::ConversionIncomplete => "Mengenumwertung unvollständig",
Self::ClockAdjusted => "Uhrzeit gestellt /Synchronisation",
Self::ImplausibleValue => "Messwert unplausibel",
Self::WrongTransformerRatio => "Falscher Wandlerfaktor",
Self::FaultyReading => "Fehlerhafte Ablesung",
Self::CalculationChanged => "Änderung der Berechnung",
Self::MeteringPointRebuilt => "Umbau der Messlokation",
Self::DataProcessingError => "Datenbearbeitungsfehler",
Self::MeteringEquipmentDefect => "Störung / Defekt Messeinrichtung",
Self::TariffTimesChanged => "Änderung Tarifschaltzeiten",
Self::TariffSwitchDeviceDefect => "Tarifschaltgerät defekt",
Self::InsufficientPulseWeight => "Impulswertigkeit nicht ausreichend",
Self::MaintenanceCalibratedDevice => "Wartungsarbeiten an geeichtem Messgerät",
Self::DeviceReportsDisturbedValues => "gestörte Werte",
Self::MaintenanceConformantDevice => {
"Wartungsarbeiten an eichrechtskonformen Messgeräten"
}
Self::ConsistencyCheckFailed => "Konsistenz- und Synchronprüfung",
Self::StatedPerMeteringPoint => {
"Grund der Ersatzwertbildung gemäß Angaben auf Ebene der Messlokation"
}
Self::RetrospectiveRequest => {
"Anforderung in die Vergangenheit, zum angeforderten Zeitpunkt liegt kein Wert vor."
}
}
}
#[must_use]
pub const fn applies_to(self, sparte: Sparte) -> bool {
match self {
Self::VoltageFailure | Self::Calibration | Self::TariffSwitchDeviceDefect => {
matches!(sparte, Sparte::Strom)
}
Self::FaultRegisterUsed
| Self::ConversionIncomplete
| Self::MaintenanceCalibratedDevice
| Self::DeviceReportsDisturbedValues
| Self::MaintenanceConformantDevice
| Self::ConsistencyCheckFailed => matches!(sparte, Sparte::Gas),
_ => true,
}
}
}
#[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 ReferenceDayMatch {
#[default]
Weekday,
DayType(crate::holiday::Bundesland),
}
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,
pub reference_days: ReferenceDayMatch,
}
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::NoAccess,
reference_days: ReferenceDayMatch::Weekday,
}
}
#[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
}
#[must_use]
pub const fn matching_day_types(mut self, land: crate::holiday::Bundesland) -> Self {
self.reference_days = ReferenceDayMatch::DayType(land);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FilledSeries {
pub intervals: Vec<MeterInterval>,
pub substitutions: Vec<SubstituteEntry>,
pub unplaced: Vec<MeterInterval>,
}
impl FilledSeries {
#[must_use]
pub fn substituted_count(&self) -> usize {
self.substitutions.len()
}
#[must_use]
pub fn placed_everything(&self) -> bool {
self.unplaced.is_empty()
}
#[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(),
unplaced: intervals.to_vec(),
};
}
let measured: BTreeMap<i64, &MeterInterval> = intervals
.iter()
.map(|iv| (iv.from.unix_timestamp(), iv))
.collect();
let mut placed: std::collections::BTreeSet<i64> = std::collections::BTreeSet::new();
let reference = PriorPeriodIndex::build(&config.prior_period_intervals, config.reference_days);
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) {
placed.insert(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 value = value.round_dp(SUBSTITUTE_DP);
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;
}
let mut unplaced: Vec<MeterInterval> = intervals
.iter()
.filter(|iv| !placed.contains(&iv.from.unix_timestamp()))
.cloned()
.collect();
unplaced.sort_by_key(|iv| (iv.from, iv.to));
FilledSeries {
intervals: out,
substitutions,
unplaced,
}
}
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)>>,
matching: ReferenceDayMatch,
}
impl PriorPeriodIndex {
fn build(intervals: &[MeterInterval], matching: ReferenceDayMatch) -> 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, matching))
.or_default()
.push((iv.from, iv.value));
}
Self { slots, matching }
}
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, self.matching))?;
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, matching: ReferenceDayMatch) -> (u8, u8, u8) {
let local = ts.to_timezone(timezones::db::europe::BERLIN);
let day = match matching {
ReferenceDayMatch::Weekday => local.weekday().number_days_from_monday(),
ReferenceDayMatch::DayType(land) => {
7 + match crate::holiday::slp_day_type(local.date(), land) {
crate::load_profile::SlpDayType::Werktag => 0,
crate::load_profile::SlpDayType::Samstag => 1,
crate::load_profile::SlpDayType::SonnFeiertag => 2,
}
}
};
(day, 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::CommunicationFailure),
);
assert_eq!(filled.substituted_count(), 2);
for entry in &filled.substitutions {
assert_eq!(entry.reason, SubstitutionReason::CommunicationFailure);
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(),
28,
"STS+Z40 lists 28 Statusanlässe in MSCONS MIG 2.4c and 2.5 alike"
);
}
#[test]
fn every_reason_carries_a_distinct_well_formed_market_code() {
let mut seen: Vec<&str> = Vec::new();
for reason in SubstitutionReason::ALL {
let code = reason.code();
assert_eq!(code.len(), 3, "{reason:?} → {code}");
assert!(
code.starts_with('Z')
&& code
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()),
"{reason:?} → {code}"
);
assert!(!seen.contains(&code), "{code} used twice");
seen.push(code);
assert_eq!(SubstitutionReason::from_code(code), Some(reason));
assert_eq!(
SubstitutionReason::from_code(&code.to_lowercase()),
Some(reason)
);
}
assert_eq!(SubstitutionReason::from_code("ZZZ"), None);
assert_eq!(SubstitutionReason::from_code(""), None);
}
#[test]
fn method_market_codes_follow_the_commodity() {
use SubstituteMethod as M;
assert_eq!(
M::LinearInterpolation.market_code(Sparte::Strom),
Some("Z92")
);
assert_eq!(M::LinearInterpolation.market_code(Sparte::Gas), Some("Z92"));
assert_eq!(
M::PriorPeriodAverage.market_code(Sparte::Strom),
Some("ZJ2")
);
assert_eq!(M::PriorPeriodAverage.market_code(Sparte::Gas), Some("Z95"));
assert_eq!(
M::LastValueCarryForward.market_code(Sparte::Gas),
Some("Z93")
);
assert_eq!(M::LastValueCarryForward.market_code(Sparte::Strom), None);
for sparte in Sparte::ALL {
assert_eq!(M::ZeroFill.market_code(sparte), None);
}
}
#[test]
fn reason_commodity_applicability_matches_the_mig() {
use SubstitutionReason as R;
assert!(R::VoltageFailure.applies_to(Sparte::Strom));
assert!(!R::VoltageFailure.applies_to(Sparte::Gas));
assert!(R::ConversionIncomplete.applies_to(Sparte::Gas));
assert!(!R::ConversionIncomplete.applies_to(Sparte::Strom));
assert!(R::NoAccess.applies_to(Sparte::Strom));
assert!(R::NoAccess.applies_to(Sparte::Gas));
}
#[test]
fn a_synthesised_value_is_cut_to_a_representable_width() {
let prior: Vec<MeterInterval> = [7, 14, 21]
.into_iter()
.map(|days_back| MeterInterval {
from: BASE - Duration::days(days_back) + Duration::minutes(15),
to: BASE - Duration::days(days_back) + Duration::minutes(30),
value: dec!(1) / Decimal::from(3u32) * Decimal::from(days_back),
quality: QualityFlag::Measured,
obis_code: None,
})
.collect();
let filled = fill_gaps(
&[iv(0, dec!(2.0)), iv(30, dec!(2.0))],
&cfg(0, 45).prior_period(prior).short_gap_threshold(0),
);
let synthesised = filled.substitutions[0].interval.value;
assert!(
synthesised.scale() <= SUBSTITUTE_DP,
"{synthesised} carries {} places",
synthesised.scale()
);
}
#[test]
fn a_holiday_gap_can_be_averaged_over_comparable_days() {
use crate::holiday::Bundesland;
use time::macros::date;
let gap_day = date!(2026 - 05 - 01);
let slot = crate::calendar::day_start_utc(gap_day) + Duration::hours(12);
let mut prior = Vec::new();
for back in 1..=7i64 {
let day = gap_day - Duration::days(back);
let at = crate::calendar::day_start_utc(day) + Duration::hours(12);
let quiet = Bundesland::By.is_holiday(day) || day.weekday() == time::Weekday::Sunday;
prior.push(MeterInterval::quarter_hour(
at,
if quiet { dec!(2) } else { dec!(10) },
));
}
let cfg = |c: FillGapsConfig| {
fill_gaps(&[], &c.prior_period(prior.clone()).short_gap_threshold(0))
};
let period = (slot, slot + Duration::minutes(15));
let by_weekday = cfg(FillGapsConfig::new(
IntervalResolution::QuarterHour,
period.0,
period.1,
));
let by_day_type =
cfg(
FillGapsConfig::new(IntervalResolution::QuarterHour, period.0, period.1)
.matching_day_types(Bundesland::By),
);
assert_eq!(by_weekday.intervals[0].value, dec!(10));
assert_eq!(by_day_type.intervals[0].value, dec!(2));
assert_eq!(
by_day_type.substitutions[0].method,
SubstituteMethod::PriorPeriodAverage
);
}
#[test]
fn an_off_grid_interval_is_reported_rather_than_dropped() {
let off_grid = MeterInterval {
from: BASE + Duration::minutes(7),
to: BASE + Duration::minutes(22),
value: dec!(9.0),
quality: QualityFlag::Measured,
obis_code: None,
};
let filled = fill_gaps(&[iv(0, dec!(2.0)), off_grid.clone()], &cfg(0, 45));
assert!(!filled.placed_everything());
assert_eq!(filled.unplaced, vec![off_grid]);
assert_eq!(filled.substituted_count(), 2);
}
}
#[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))
);
}
}