use crate::interval::{MeterInterval, QualityFlag};
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(test)]
use rust_decimal_macros::dec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, 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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SubstitutionReason {
NoMeasurementAvailable,
MeterFault,
GatewayCommFailure,
PlausibilityCheckFailed,
ManualCorrection,
MeterExchangeInterpolation,
DstSpringForward,
BillingPeriodGapFill,
Other,
}
impl SubstitutionReason {
#[must_use]
pub fn description(&self) -> &'static str {
match self {
Self::NoMeasurementAvailable => "Kein Messwert verfügbar (§17 Abs. 1 MessZV)",
Self::MeterFault => "Zählerdefekt oder Kommunikationsstörung",
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::DstSpringForward => "Sommerzeit-Umstellung — fehlende Stunde",
Self::BillingPeriodGapFill => "Abrechnungszeitraum-Lücke",
Self::Other => "Sonstiger dokumentierter Grund",
}
}
}
pub struct FillGapsConfig {
pub method: SubstituteMethod,
pub prior_period_intervals: Vec<MeterInterval>,
pub short_gap_threshold: usize,
pub reason: Option<SubstitutionReason>,
}
impl Default for FillGapsConfig {
fn default() -> Self {
Self {
method: SubstituteMethod::default(),
prior_period_intervals: Vec::new(),
short_gap_threshold: 3,
reason: None,
}
}
}
impl FillGapsConfig {
#[must_use]
pub fn prior_period(prior_period_intervals: Vec<MeterInterval>) -> Self {
Self {
method: SubstituteMethod::PriorPeriodAverage,
prior_period_intervals,
short_gap_threshold: 3,
reason: None,
}
}
#[must_use]
pub fn zero_fill() -> Self {
Self {
method: SubstituteMethod::ZeroFill,
prior_period_intervals: Vec::new(),
short_gap_threshold: 0,
reason: None,
}
}
}
#[must_use]
pub fn fill_gaps_with_config(
intervals: &[MeterInterval],
expected_interval_secs: i64,
from: OffsetDateTime,
to: OffsetDateTime,
config: &FillGapsConfig,
) -> Vec<MeterInterval> {
use time::Duration;
if expected_interval_secs <= 0 {
return intervals.to_vec();
}
let mut sorted = intervals.to_vec();
sorted.sort_by_key(|iv| iv.from);
use std::collections::HashMap;
let existing: HashMap<i64, &MeterInterval> = sorted
.iter()
.map(|iv| (iv.from.unix_timestamp(), iv))
.collect();
let mut prior_sorted = config.prior_period_intervals.clone();
prior_sorted.sort_by_key(|iv| iv.from);
let mut result: Vec<MeterInterval> = Vec::new();
let mut cursor = from;
while cursor < to {
let next = cursor + Duration::seconds(expected_interval_secs);
let ts = cursor.unix_timestamp();
if let Some(&iv) = existing.get(&ts) {
result.push(iv.clone());
} else {
let gap_len = count_consecutive_gaps(&sorted, cursor, expected_interval_secs);
let effective_method = if gap_len <= config.short_gap_threshold && gap_len > 0 {
SubstituteMethod::LinearInterpolation
} else {
config.method
};
let sub_value = synthesise_value(
&sorted,
cursor,
next,
&result,
effective_method,
&prior_sorted,
);
result.push(MeterInterval {
from: cursor,
to: next,
value_kwh: sub_value,
quality: QualityFlag::Substituted,
obis_code: sorted.first().and_then(|iv| iv.obis_code.clone()),
});
}
cursor = next;
}
result
}
fn count_consecutive_gaps(
sorted: &[MeterInterval],
gap_start: OffsetDateTime,
interval_secs: i64,
) -> usize {
use time::Duration;
let existing_starts: std::collections::HashSet<i64> =
sorted.iter().map(|iv| iv.from.unix_timestamp()).collect();
let mut count = 0;
let mut cursor = gap_start;
while !existing_starts.contains(&cursor.unix_timestamp()) {
count += 1;
cursor += Duration::seconds(interval_secs);
if count > 100 {
break; }
}
count
}
#[must_use]
pub fn fill_gaps(
intervals: &[MeterInterval],
expected_interval_secs: i64,
from: OffsetDateTime,
to: OffsetDateTime,
) -> Vec<MeterInterval> {
fill_gaps_with_config(
intervals,
expected_interval_secs,
from,
to,
&FillGapsConfig::default(),
)
}
fn synthesise_value(
all_sorted: &[MeterInterval],
from: OffsetDateTime,
_to: OffsetDateTime,
prior_filled: &[MeterInterval],
method: SubstituteMethod,
prior_period: &[MeterInterval],
) -> Decimal {
match method {
SubstituteMethod::ZeroFill => Decimal::ZERO,
SubstituteMethod::PriorPeriodAverage => {
let target_time = (from.hour(), from.minute(), from.second());
let matches: Vec<Decimal> = prior_period
.iter()
.filter(|iv| {
iv.quality.is_billable()
&& (iv.from.hour(), iv.from.minute(), iv.from.second()) == target_time
})
.map(|iv| iv.value_kwh)
.collect();
if !matches.is_empty() {
let sum: Decimal = matches.iter().sum();
return sum / Decimal::from(matches.len() as u32);
}
prior_filled
.iter()
.rev()
.find(|iv| iv.quality.is_billable())
.map_or(Decimal::ZERO, |iv| iv.value_kwh)
}
SubstituteMethod::LastValueCarryForward => prior_filled
.iter()
.rev()
.find(|iv| iv.quality.is_billable())
.or_else(|| {
all_sorted
.iter()
.find(|iv| iv.from > from && iv.quality.is_billable())
})
.map_or(Decimal::ZERO, |iv| iv.value_kwh),
SubstituteMethod::LinearInterpolation => {
let preceding = prior_filled
.iter()
.rev()
.find(|iv| iv.quality.is_billable());
let following = all_sorted
.iter()
.find(|iv| iv.from > from && iv.quality.is_billable());
match (preceding, following) {
(Some(p), Some(f)) => {
let total_secs = (f.from - p.from).whole_seconds();
let elapsed_secs = (from - p.from).whole_seconds();
if total_secs > 0 {
let t = Decimal::from(elapsed_secs) / Decimal::from(total_secs);
p.value_kwh + t * (f.value_kwh - p.value_kwh)
} else {
p.value_kwh
}
}
(Some(p), None) => p.value_kwh,
(None, Some(f)) => f.value_kwh,
(None, None) => Decimal::ZERO,
}
}
}
}
#[must_use]
pub fn linear_interpolation(before: &MeterInterval, after: &MeterInterval) -> MeterInterval {
let total_secs = (after.from - before.to).whole_seconds() as f64;
let mid_secs = total_secs / 2.0;
let t = if total_secs > 0.0 {
mid_secs / total_secs
} else {
0.5
};
let t_dec = Decimal::try_from(t).unwrap_or_else(|_| Decimal::new(5, 1));
let value = before.value_kwh + t_dec * (after.value_kwh - before.value_kwh);
MeterInterval {
from: before.to,
to: after.from,
value_kwh: value,
quality: QualityFlag::Substituted,
obis_code: before.obis_code.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
fn iv(from_h: i64, from_m: i64, kwh: f64) -> MeterInterval {
let base = datetime!(2026-01-01 0:00 UTC);
let start = base + time::Duration::hours(from_h) + time::Duration::minutes(from_m);
MeterInterval {
from: start,
to: start + time::Duration::minutes(15),
value_kwh: Decimal::try_from(kwh).unwrap(),
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn fill_gaps_single_gap() {
let intervals = vec![iv(0, 0, 2.0), iv(0, 30, 2.4)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let filled = fill_gaps(&intervals, 900, from, to);
assert_eq!(filled.len(), 3, "should have 3 intervals after gap fill");
assert_eq!(filled[0].quality, QualityFlag::Measured);
assert_eq!(
filled[1].quality,
QualityFlag::Substituted,
"gap must be Substituted"
);
assert_eq!(filled[2].quality, QualityFlag::Measured);
assert!(
filled[1].value_kwh > dec!(1.9) && filled[1].value_kwh < dec!(2.5),
"interpolated value {} out of range",
filled[1].value_kwh
);
}
#[test]
fn fill_gaps_no_gaps() {
let intervals = vec![iv(0, 0, 2.0), iv(0, 15, 2.1), iv(0, 30, 2.2)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let filled = fill_gaps(&intervals, 900, from, to);
assert_eq!(filled.len(), 3);
assert!(filled.iter().all(|iv| iv.quality == QualityFlag::Measured));
}
#[test]
fn fill_gaps_carry_forward_at_end() {
let intervals = vec![iv(0, 0, 3.0)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:30 UTC);
let filled = fill_gaps(&intervals, 900, from, to);
assert_eq!(filled.len(), 2);
assert_eq!(filled[0].quality, QualityFlag::Measured);
assert_eq!(filled[1].quality, QualityFlag::Substituted);
assert_eq!(
filled[1].value_kwh,
dec!(3.0),
"carry-forward from last known"
);
}
#[test]
fn linear_interpolation_midpoint() {
let before = iv(0, 0, 2.0);
let mut after = iv(0, 30, 4.0);
after.from = before.to + time::Duration::minutes(15); after.to = after.from + time::Duration::minutes(15);
let sub = linear_interpolation(&before, &after);
assert_eq!(sub.quality, QualityFlag::Substituted);
assert!(
sub.value_kwh > dec!(2.5) && sub.value_kwh < dec!(3.5),
"interpolated value {}",
sub.value_kwh
);
}
#[test]
fn fill_gaps_multiple_gaps() {
let intervals = vec![iv(0, 0, 2.0), iv(0, 45, 2.6)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 1:00 UTC);
let filled = fill_gaps(&intervals, 900, from, to);
assert_eq!(filled.len(), 4);
assert_eq!(filled[1].quality, QualityFlag::Substituted);
assert_eq!(filled[2].quality, QualityFlag::Substituted);
}
#[test]
fn fill_gaps_prior_period_average_uses_matching_slot() {
let prior_week_base = datetime!(2025-12-25 0:00 UTC); let prior_reading = MeterInterval {
from: prior_week_base + time::Duration::minutes(15),
to: prior_week_base + time::Duration::minutes(30),
value_kwh: dec!(3.0),
quality: QualityFlag::Measured,
obis_code: None,
};
let intervals = vec![iv(0, 0, 2.0), iv(0, 30, 4.0)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let config = FillGapsConfig::prior_period(vec![prior_reading]);
let filled = fill_gaps_with_config(&intervals, 900, from, to, &config);
assert_eq!(filled.len(), 3);
assert_eq!(filled[1].quality, QualityFlag::Substituted);
assert_eq!(
filled[1].value_kwh,
dec!(3.0),
"PriorPeriodAverage must use prior-week same-slot value"
);
}
#[test]
fn fill_gaps_prior_period_average_fallback_to_carry_forward() {
let prior_reading = MeterInterval {
from: datetime!(2025-12-25 1:00 UTC), to: datetime!(2025-12-25 1:15 UTC),
value_kwh: dec!(5.0),
quality: QualityFlag::Measured,
obis_code: None,
};
let intervals = vec![iv(0, 0, 2.5), iv(0, 30, 4.0)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let config = FillGapsConfig {
method: SubstituteMethod::PriorPeriodAverage,
prior_period_intervals: vec![prior_reading],
short_gap_threshold: 0,
reason: None,
};
let filled = fill_gaps_with_config(&intervals, 900, from, to, &config);
assert_eq!(filled.len(), 3);
assert_eq!(filled[1].quality, QualityFlag::Substituted);
assert_eq!(
filled[1].value_kwh,
dec!(2.5),
"fallback must carry forward last known value"
);
}
#[test]
fn fill_gaps_zero_fill_config() {
let intervals = vec![iv(0, 0, 2.0), iv(0, 30, 2.0)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let filled = fill_gaps_with_config(&intervals, 900, from, to, &FillGapsConfig::zero_fill());
assert_eq!(filled.len(), 3);
assert_eq!(filled[1].quality, QualityFlag::Substituted);
assert_eq!(filled[1].value_kwh, dec!(0), "ZeroFill must produce 0");
}
#[test]
fn fill_gaps_short_gap_always_linear_even_with_zero_fill_method() {
let intervals = vec![iv(0, 0, 2.0), iv(0, 30, 4.0)];
let from = datetime!(2026-01-01 0:00 UTC);
let to = datetime!(2026-01-01 0:45 UTC);
let config = FillGapsConfig {
method: SubstituteMethod::ZeroFill,
prior_period_intervals: vec![],
short_gap_threshold: 3,
reason: None,
};
let filled = fill_gaps_with_config(&intervals, 900, from, to, &config);
assert_eq!(filled.len(), 3);
assert!(
filled[1].value_kwh > dec!(1.0),
"short gap must use linear interpolation, not ZeroFill"
);
}
}