use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::interval::MeterInterval;
use time_tz::{OffsetDateTimeExt as _, timezones};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ValidationSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ValidationRuleId {
GapDetected,
OverlapDetected,
NegativeEnergy,
ImpossibleSpike,
SuspiciousZeroRun,
InconsistentIntervalLength,
DstAmbiguity,
FutureTimestamp,
NonBillableQuality,
RegisterRollover,
UnorderedSeries,
}
impl std::fmt::Display for ValidationRuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let code = match self {
Self::GapDetected => "V01",
Self::OverlapDetected => "V02",
Self::NegativeEnergy => "V03",
Self::ImpossibleSpike => "V04",
Self::SuspiciousZeroRun => "V05",
Self::InconsistentIntervalLength => "V06",
Self::DstAmbiguity => "V07",
Self::FutureTimestamp => "V08",
Self::NonBillableQuality => "V09",
Self::RegisterRollover => "V10",
Self::UnorderedSeries => "V11",
};
write!(f, "{code}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ValidationIssue {
pub rule_id: ValidationRuleId,
pub severity: ValidationSeverity,
pub message: String,
pub interval_index: Option<usize>,
pub affected_from: Option<OffsetDateTime>,
pub affected_value_kwh: Option<Decimal>,
}
impl ValidationIssue {
fn new(
rule_id: ValidationRuleId,
severity: ValidationSeverity,
message: impl Into<String>,
) -> Self {
Self {
rule_id,
severity,
message: message.into(),
interval_index: None,
affected_from: None,
affected_value_kwh: None,
}
}
fn at(mut self, idx: usize, interval: &MeterInterval) -> Self {
self.interval_index = Some(idx);
self.affected_from = Some(interval.from);
self.affected_value_kwh = Some(interval.value_kwh);
self
}
#[must_use]
pub fn blocks_billing(&self) -> bool {
self.severity == ValidationSeverity::Error
}
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub expected_interval_secs: Option<u32>,
pub spike_factor: f64,
pub zero_run_threshold: usize,
pub negative_energy_is_error: bool,
pub now: Option<OffsetDateTime>,
pub max_plant_power_kw: Option<Decimal>,
pub rollover_threshold_kwh: Option<Decimal>,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
expected_interval_secs: Some(900), spike_factor: 10.0,
zero_run_threshold: 4,
negative_energy_is_error: true,
now: None,
max_plant_power_kw: None,
rollover_threshold_kwh: Some(Decimal::from(50_000u32)),
}
}
}
impl ValidationConfig {
#[must_use]
pub fn rlm_strom_15min() -> Self {
Self::default()
}
#[must_use]
pub fn gas_hourly() -> Self {
Self {
expected_interval_secs: Some(3600),
..Self::default()
}
}
#[must_use]
pub fn bidirectional() -> Self {
Self {
negative_energy_is_error: false,
..Self::default()
}
}
#[must_use]
pub fn without_spike_detection(mut self) -> Self {
self.spike_factor = f64::INFINITY;
self
}
#[must_use]
pub fn with_plant_capacity_kw(mut self, kw: Decimal) -> Self {
self.max_plant_power_kw = Some(kw);
self
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub issues: Vec<ValidationIssue>,
}
impl ValidationResult {
#[must_use]
pub fn is_clean(&self) -> bool {
self.issues.is_empty()
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.issues
.iter()
.any(|i| i.severity == ValidationSeverity::Error)
}
#[must_use]
pub fn billing_block_count(&self) -> usize {
self.issues.iter().filter(|i| i.blocks_billing()).count()
}
pub fn by_severity(
&self,
severity: ValidationSeverity,
) -> impl Iterator<Item = &ValidationIssue> {
self.issues.iter().filter(move |i| i.severity == severity)
}
}
#[must_use]
pub fn validate_intervals(
intervals: &[MeterInterval],
config: &ValidationConfig,
) -> ValidationResult {
let mut issues: Vec<ValidationIssue> = Vec::new();
if intervals.is_empty() {
return ValidationResult { issues };
}
let mut order: Vec<usize> = (0..intervals.len()).collect();
order.sort_by_key(|&i| (intervals[i].from, intervals[i].to));
if let Some((pos, idx)) = order
.iter()
.enumerate()
.find(|&(pos, &i)| pos != i)
.map(|(pos, &i)| (pos, i))
{
issues.push(
ValidationIssue::new(
ValidationRuleId::UnorderedSeries,
ValidationSeverity::Warning,
format!(
"series is not sorted ascending by `from`: position {pos} holds the \
interval starting {}, which belongs at index {idx} — the remaining \
rules were evaluated in timestamp order",
intervals[idx].from
),
)
.at(idx, &intervals[idx]),
);
}
let total_kwh: Decimal = intervals.iter().map(|iv| iv.value_kwh).sum();
let rolling_mean_dec = if intervals.is_empty() {
Decimal::ONE
} else {
total_kwh / Decimal::from(intervals.len() as u32)
};
let rolling_mean: f64 = rolling_mean_dec
.to_string()
.parse::<f64>()
.unwrap_or(1.0)
.max(f64::EPSILON);
let mut zero_run = 0usize;
for (pos, &idx) in order.iter().enumerate() {
let iv = &intervals[idx];
if config.negative_energy_is_error && iv.value_kwh < Decimal::ZERO {
issues.push(
ValidationIssue::new(
ValidationRuleId::NegativeEnergy,
ValidationSeverity::Error,
format!("negative energy {} kWh at {}", iv.value_kwh, iv.from),
)
.at(idx, iv),
);
}
if config.spike_factor.is_finite()
&& rolling_mean > 0.0
&& let Ok(v) = iv.value_kwh.to_string().parse::<f64>()
&& v > config.spike_factor * rolling_mean
{
issues.push(
ValidationIssue::new(
ValidationRuleId::ImpossibleSpike,
ValidationSeverity::Warning,
format!(
"spike {:.3} kWh is {:.1}× rolling mean {:.3} kWh at {} (V04)",
v,
v / rolling_mean,
rolling_mean,
iv.from
),
)
.at(idx, iv),
);
}
if let Some(cap_kw) = config.max_plant_power_kw
&& let Some(power_kw) = iv.demand_kw()
&& power_kw > cap_kw
{
issues.push(
ValidationIssue::new(
ValidationRuleId::ImpossibleSpike,
ValidationSeverity::Error,
format!(
"average power {power_kw} kW exceeds plant capacity {cap_kw} kW at {} (V04)",
iv.from
),
)
.at(idx, iv),
);
}
if iv.value_kwh.is_zero() {
zero_run += 1;
if zero_run == config.zero_run_threshold {
let start_idx = order[pos + 1 - config.zero_run_threshold];
issues.push(
ValidationIssue::new(
ValidationRuleId::SuspiciousZeroRun,
ValidationSeverity::Warning,
format!(
"{} consecutive zero intervals starting at index {}",
config.zero_run_threshold, start_idx
),
)
.at(start_idx, &intervals[start_idx]),
);
}
} else {
zero_run = 0;
}
if let Some(expected_secs) = config.expected_interval_secs {
let actual_secs = (iv.to - iv.from).whole_seconds();
if actual_secs != expected_secs as i64 {
issues.push(
ValidationIssue::new(
ValidationRuleId::InconsistentIntervalLength,
ValidationSeverity::Warning,
format!(
"expected {}s interval, got {}s at {}",
expected_secs, actual_secs, iv.from
),
)
.at(idx, iv),
);
}
}
if let Some(now) = config.now
&& iv.from > now
{
issues.push(
ValidationIssue::new(
ValidationRuleId::FutureTimestamp,
ValidationSeverity::Warning,
format!("interval starts in the future: {} > now {}", iv.from, now),
)
.at(idx, iv),
);
}
if !iv.quality.is_billable() {
issues.push(
ValidationIssue::new(
ValidationRuleId::NonBillableQuality,
ValidationSeverity::Error,
format!(
"quality {:?} is not billable at {} — substitute value required (§ 60 Abs. 2 MsbG)",
iv.quality, iv.from
),
)
.at(idx, iv),
);
}
if pos > 0 {
let prev = &intervals[order[pos - 1]];
if iv.from < prev.to {
issues.push(
ValidationIssue::new(
ValidationRuleId::OverlapDetected,
ValidationSeverity::Error,
format!(
"interval [{}, {}) overlaps previous [{}, {})",
iv.from, iv.to, prev.from, prev.to
),
)
.at(idx, iv),
);
}
if let Some(threshold) = config.rollover_threshold_kwh {
let drop = prev.value_kwh - iv.value_kwh;
if drop >= threshold {
issues.push(
ValidationIssue::new(
ValidationRuleId::RegisterRollover,
ValidationSeverity::Warning,
format!(
"value dropped {:.3} kWh ({:.3} → {:.3}) at {} — \
possible meter rollover (WiM Gerätewechsel-Dokumentation)",
drop, prev.value_kwh, iv.value_kwh, iv.from
),
)
.at(idx, iv),
);
}
}
}
}
if let Some(expected_secs) = config.expected_interval_secs {
for (pos, window) in order.windows(2).enumerate() {
let (a, b) = (&intervals[window[0]], &intervals[window[1]]);
let gap_secs = (b.from - a.to).whole_seconds();
if gap_secs >= expected_secs as i64 {
let gap_count = (gap_secs / expected_secs as i64) as usize;
issues.push(ValidationIssue {
rule_id: ValidationRuleId::GapDetected,
severity: ValidationSeverity::Error,
message: format!(
"gap of {} interval(s) between {} and {} — substitute values required (§ 60 Abs. 2 MsbG)",
gap_count, a.to, b.from
),
interval_index: Some(order[pos + 1]),
affected_from: Some(a.to),
affected_value_kwh: None,
});
}
}
}
let ordered: Vec<&MeterInterval> = order.iter().map(|&i| &intervals[i]).collect();
issues.extend(detect_dst_ambiguity(&ordered));
issues.sort_by_key(|i| i.interval_index.unwrap_or(usize::MAX));
ValidationResult { issues }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::datetime;
#[test]
fn findings_do_not_depend_on_input_order() {
let sorted: Vec<MeterInterval> = (0..8).map(|i| iv_15min(i * 15, dec!(2.0))).collect();
let cfg = ValidationConfig {
expected_interval_secs: Some(900),
..ValidationConfig::default()
};
let clean = validate_intervals(&sorted, &cfg);
assert!(
clean.is_clean(),
"baseline must be clean: {:?}",
clean.issues
);
let mut shuffled = sorted.clone();
shuffled.reverse();
let result = validate_intervals(&shuffled, &cfg);
let non_order_issues: Vec<_> = result
.issues
.iter()
.filter(|i| i.rule_id != ValidationRuleId::UnorderedSeries)
.collect();
assert!(
non_order_issues.is_empty(),
"reordering must not invent findings: {non_order_issues:?}"
);
let v11: Vec<_> = result
.issues
.iter()
.filter(|i| i.rule_id == ValidationRuleId::UnorderedSeries)
.collect();
assert_eq!(v11.len(), 1, "V11 is a series-level finding");
assert_eq!(v11[0].rule_id.to_string(), "V11");
assert!(
!v11[0].blocks_billing(),
"disorder is a warning, not a block"
);
}
#[test]
fn a_real_gap_is_found_in_any_order_with_caller_indices() {
let cfg = ValidationConfig {
expected_interval_secs: Some(900),
..ValidationConfig::default()
};
let sorted = vec![
iv_15min(0, dec!(2.0)),
iv_15min(15, dec!(2.0)),
iv_15min(45, dec!(2.0)),
];
let from_sorted = validate_intervals(&sorted, &cfg);
assert_eq!(
from_sorted
.issues
.iter()
.filter(|i| i.rule_id == ValidationRuleId::GapDetected)
.count(),
1
);
let rotated = vec![
iv_15min(45, dec!(2.0)),
iv_15min(0, dec!(2.0)),
iv_15min(15, dec!(2.0)),
];
let from_rotated = validate_intervals(&rotated, &cfg);
let gaps: Vec<_> = from_rotated
.issues
.iter()
.filter(|i| i.rule_id == ValidationRuleId::GapDetected)
.collect();
assert_eq!(gaps.len(), 1, "the same single gap, not a different count");
assert_eq!(gaps[0].interval_index, Some(0));
}
fn iv(from: OffsetDateTime, to: OffsetDateTime, kwh: rust_decimal::Decimal) -> MeterInterval {
MeterInterval {
from,
to,
value_kwh: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
fn iv_15min(start_min: i64, kwh: rust_decimal::Decimal) -> MeterInterval {
let base = datetime!(2026-06-01 0:00 UTC);
let from = base + time::Duration::minutes(start_min);
let to = from + time::Duration::minutes(15);
iv(from, to, kwh)
}
#[test]
fn plant_capacity_ceiling_flags_impossible_power() {
let intervals = vec![
iv_15min(0, dec!(2.5)),
iv_15min(15, dec!(30.0)),
iv_15min(30, dec!(2.8)),
];
let cfg = ValidationConfig::default()
.without_spike_detection()
.with_plant_capacity_kw(dec!(30));
let result = validate_intervals(&intervals, &cfg);
let hard = result
.issues
.iter()
.filter(|i| {
i.rule_id == ValidationRuleId::ImpossibleSpike
&& i.severity == ValidationSeverity::Error
})
.count();
assert_eq!(hard, 1, "issues: {:?}", result.issues);
assert_eq!(result.issues[0].interval_index, Some(1));
}
#[test]
fn plant_capacity_ceiling_passes_plausible_power() {
let intervals = vec![iv_15min(0, dec!(5.0)), iv_15min(15, dec!(5.0))];
let cfg = ValidationConfig::default()
.without_spike_detection()
.with_plant_capacity_kw(dec!(30));
assert!(validate_intervals(&intervals, &cfg).is_clean());
}
#[test]
fn clean_series_passes() {
let intervals = vec![
iv_15min(0, dec!(2.5)),
iv_15min(15, dec!(2.3)),
iv_15min(30, dec!(2.8)),
];
let result = validate_intervals(&intervals, &ValidationConfig::default());
assert!(
result.is_clean(),
"expected clean, got: {:?}",
result.issues
);
}
#[test]
fn gap_detected() {
let intervals = vec![
iv_15min(0, dec!(2.5)),
iv_15min(30, dec!(2.3)), ];
let result = validate_intervals(&intervals, &ValidationConfig::default());
assert!(result.has_errors());
let gap = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::GapDetected);
assert!(gap.is_some(), "V01 gap issue not found");
}
#[test]
fn overlap_detected() {
let base = datetime!(2026-06-01 0:00 UTC);
let intervals = vec![
iv(base, base + time::Duration::minutes(20), dec!(2.5)),
iv(
base + time::Duration::minutes(15),
base + time::Duration::minutes(30),
dec!(2.3),
),
];
let result = validate_intervals(&intervals, &ValidationConfig::default());
let overlap = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::OverlapDetected);
assert!(overlap.is_some(), "V02 overlap issue not found");
assert!(overlap.unwrap().blocks_billing());
}
#[test]
fn negative_energy_error() {
let intervals = vec![iv_15min(0, dec!(-1.5))];
let result = validate_intervals(&intervals, &ValidationConfig::default());
let neg = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::NegativeEnergy);
assert!(neg.is_some(), "V03 negative energy issue not found");
assert!(neg.unwrap().blocks_billing());
}
#[test]
fn bidirectional_allows_negative() {
let intervals = vec![iv_15min(0, dec!(-1.5))];
let result = validate_intervals(&intervals, &ValidationConfig::bidirectional());
assert!(!result.has_errors(), "bidirectional should allow negative");
}
#[test]
fn spike_detection() {
let intervals = vec![
iv_15min(0, dec!(2.5)),
iv_15min(15, dec!(2.5)),
iv_15min(30, dec!(2.5)),
iv_15min(45, dec!(20.0)), iv_15min(60, dec!(2.5)),
];
let config = ValidationConfig {
spike_factor: 3.0,
..ValidationConfig::default()
};
let result = validate_intervals(&intervals, &config);
let spike = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::ImpossibleSpike);
assert!(spike.is_some(), "V04 spike not detected");
assert_eq!(spike.unwrap().severity, ValidationSeverity::Warning);
}
#[test]
fn zero_run_detected() {
let intervals = vec![
iv_15min(0, dec!(2.5)),
iv_15min(15, dec!(0)),
iv_15min(30, dec!(0)),
iv_15min(45, dec!(0)),
iv_15min(60, dec!(0)), iv_15min(75, dec!(2.5)),
];
let result = validate_intervals(&intervals, &ValidationConfig::default());
let zero = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::SuspiciousZeroRun);
assert!(zero.is_some(), "V05 zero run not detected");
}
#[test]
fn non_billable_quality_is_error() {
let base = datetime!(2026-06-01 0:00 UTC);
let interval = MeterInterval {
from: base,
to: base + time::Duration::minutes(15),
value_kwh: dec!(2.5),
quality: QualityFlag::Faulty,
obis_code: None,
};
let result = validate_intervals(&[interval], &ValidationConfig::default());
let nq = result
.issues
.iter()
.find(|i| i.rule_id == ValidationRuleId::NonBillableQuality);
assert!(nq.is_some(), "V09 non-billable quality not detected");
assert!(nq.unwrap().blocks_billing());
}
#[test]
fn dst_spring_forward_no_false_gap_in_utc() {
let base = datetime!(2026-03-29 0:45 UTC); let intervals = vec![
iv(base, base + time::Duration::minutes(15), dec!(2.5)),
iv(
base + time::Duration::minutes(15),
base + time::Duration::minutes(30),
dec!(2.3),
),
iv(
base + time::Duration::minutes(30),
base + time::Duration::minutes(45),
dec!(2.1),
),
];
let result = validate_intervals(&intervals, &ValidationConfig::rlm_strom_15min());
assert!(
result.is_clean(),
"UTC series should have no gap at DST spring-forward: {:?}",
result.issues
);
}
#[test]
fn dst_fall_back_no_overlap_in_utc() {
let base = datetime!(2026-10-25 0:45 UTC);
let intervals: Vec<MeterInterval> = (0..8)
.map(|i| {
let from = base + time::Duration::minutes(i * 15);
iv(from, from + time::Duration::minutes(15), dec!(2.5))
})
.collect();
let result = validate_intervals(&intervals, &ValidationConfig::rlm_strom_15min());
assert!(
result.is_clean(),
"UTC series should have no overlap at DST fall-back: {:?}",
result.issues
);
}
}
fn detect_dst_ambiguity(intervals: &[&MeterInterval]) -> Vec<ValidationIssue> {
let berlin = timezones::db::europe::BERLIN;
let Some(first) = intervals.first() else {
return Vec::new();
};
let Some(last) = intervals.last() else {
return Vec::new();
};
let start_local = first.from.to_timezone(berlin);
let end_local = last.to.to_timezone(berlin);
if start_local.time() != time::Time::MIDNIGHT {
return Vec::new();
}
if (end_local.date() - start_local.date()).whole_days() > 1 {
return Vec::new();
}
let span_hours = (last.to - first.from).whole_hours();
let is_fall_back_day =
start_local.offset().whole_hours() == 2 && end_local.offset().whole_hours() == 1;
if !is_fall_back_day {
return Vec::new();
}
let covered: i64 = intervals
.iter()
.map(|iv| (iv.to - iv.from).whole_seconds())
.sum();
const TWENTY_FIVE_HOURS: i64 = 25 * 3600;
if covered >= TWENTY_FIVE_HOURS {
return Vec::new();
}
vec![ValidationIssue {
rule_id: ValidationRuleId::DstAmbiguity,
severity: ValidationSeverity::Error,
message: format!(
"local day {} is a DST fall-back day (25 hours) but the series covers only \
{covered} s over a {span_hours} h span — the repeated hour 02:00–03:00 was \
collapsed, so one hour of energy is missing and the surviving intervals are \
ambiguous between the two passes",
start_local.date()
),
interval_index: Some(0),
affected_from: Some(first.from),
affected_value_kwh: None,
}]
}
#[cfg(test)]
mod v07_tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::datetime;
fn qh(start: OffsetDateTime, n: i64) -> Vec<MeterInterval> {
(0..n)
.map(|i| {
let from = start + time::Duration::minutes(15 * i);
MeterInterval {
from,
to: from + time::Duration::minutes(15),
value_kwh: dec!(1.0),
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
#[test]
fn a_complete_25_hour_fall_back_day_is_clean() {
let intervals = qh(datetime!(2026-10-24 22:00 UTC), 100);
assert!(
detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>()).is_empty(),
"a full 25-hour day must not raise V07"
);
}
#[test]
fn a_collapsed_fall_back_day_raises_v07() {
let intervals = qh(datetime!(2026-10-24 22:00 UTC), 96);
let issues = detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>());
assert_eq!(issues.len(), 1, "expected V07: {issues:?}");
assert_eq!(issues[0].rule_id, ValidationRuleId::DstAmbiguity);
}
#[test]
fn a_truncated_window_across_the_boundary_is_not_flagged() {
let intervals = qh(datetime!(2026-10-25 0:45 UTC), 4);
assert!(
detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>()).is_empty(),
"a truncated query window must not be reported as collapsed"
);
}
#[test]
fn an_ordinary_day_raises_nothing() {
let intervals = qh(datetime!(2026-07-14 22:00 UTC), 96);
assert!(detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>()).is_empty());
}
#[test]
fn spring_forward_raises_nothing() {
let intervals = qh(datetime!(2026-03-28 23:00 UTC), 92);
assert!(detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>()).is_empty());
}
#[test]
fn v07_is_emitted_by_validate_intervals() {
let intervals = qh(datetime!(2026-10-24 22:00 UTC), 96);
let report = validate_intervals(&intervals, &ValidationConfig::default());
assert!(
report
.issues
.iter()
.any(|i| i.rule_id == ValidationRuleId::DstAmbiguity),
"validate_intervals must surface V07: {:?}",
report.issues
);
}
}