use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::interval::MeterInterval;
#[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,
}
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",
};
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 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,
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
}
}
#[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 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 (idx, iv) in intervals.iter().enumerate() {
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 iv.value_kwh.is_zero() {
zero_run += 1;
if zero_run == config.zero_run_threshold {
let start_idx = idx + 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 (§17 MessZV)",
iv.quality, iv.from
),
)
.at(idx, iv),
);
}
if idx > 0 {
let prev = &intervals[idx - 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 (§14 MessZV)",
drop, prev.value_kwh, iv.value_kwh, iv.from
),
)
.at(idx, iv),
);
}
}
}
}
if let Some(expected_secs) = config.expected_interval_secs {
for (idx, window) in intervals.windows(2).enumerate() {
let (a, b) = (&window[0], &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 (§17 MessZV)",
gap_count, a.to, b.from
),
interval_index: Some(idx + 1),
affected_from: Some(a.to),
affected_value_kwh: None,
});
}
}
}
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_macros::dec;
use time::macros::datetime;
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 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
);
}
}