use rust_decimal::Decimal;
use rust_decimal::prelude::ToPrimitive as _;
use time::{Duration, 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,
StatisticalOutlier,
SuspiciousZeroRun,
InconsistentIntervalLength,
DstAmbiguity,
FutureTimestamp,
NonBillableQuality,
UnorderedSeries,
ImplausiblePower,
}
impl ValidationRuleId {
pub const ALL: [Self; 11] = [
Self::GapDetected,
Self::OverlapDetected,
Self::NegativeEnergy,
Self::StatisticalOutlier,
Self::SuspiciousZeroRun,
Self::InconsistentIntervalLength,
Self::DstAmbiguity,
Self::FutureTimestamp,
Self::NonBillableQuality,
Self::UnorderedSeries,
Self::ImplausiblePower,
];
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::GapDetected => "V01",
Self::OverlapDetected => "V02",
Self::NegativeEnergy => "V03",
Self::StatisticalOutlier => "V04",
Self::SuspiciousZeroRun => "V05",
Self::InconsistentIntervalLength => "V06",
Self::DstAmbiguity => "V07",
Self::FutureTimestamp => "V08",
Self::NonBillableQuality => "V09",
Self::UnorderedSeries => "V11",
Self::ImplausiblePower => "V12",
}
}
}
impl std::fmt::Display for ValidationRuleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.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: 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: None,
}
}
fn at(mut self, idx: usize, interval: &MeterInterval) -> Self {
self.interval_index = Some(idx);
self.affected_from = Some(interval.from);
self.affected_value = Some(interval.value);
self
}
fn anchored_at(mut self, from: OffsetDateTime) -> Self {
self.affected_from = Some(from);
self
}
#[must_use]
pub fn blocks_billing(&self) -> bool {
self.severity == ValidationSeverity::Error
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationConfig {
pub expected_interval_secs: Option<u32>,
pub period: Option<(OffsetDateTime, OffsetDateTime)>,
pub outlier_sigma: Option<f64>,
pub outlier_window: usize,
pub outlier_min_sigma: f64,
pub zero_run_threshold: usize,
pub negative_energy_is_error: bool,
pub now: Option<OffsetDateTime>,
pub max_plant_power_kw: Option<Decimal>,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
expected_interval_secs: Some(900), period: None,
outlier_sigma: Some(6.0),
outlier_window: 12,
outlier_min_sigma: 0.0,
zero_run_threshold: 4,
negative_energy_is_error: true,
now: None,
max_plant_power_kw: None,
}
}
}
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),
outlier_window: 12,
..Self::default()
}
}
#[must_use]
pub fn bidirectional() -> Self {
Self {
negative_energy_is_error: false,
..Self::default()
}
}
#[must_use]
pub fn without_outlier_detection(mut self) -> Self {
self.outlier_sigma = None;
self
}
#[must_use]
pub fn with_plant_capacity_kw(mut self, kw: Decimal) -> Self {
self.max_plant_power_kw = Some(kw);
self
}
#[must_use]
pub fn over_period(mut self, from: OffsetDateTime, to: OffsetDateTime) -> Self {
self.period = Some((from, to));
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)
}
pub fn by_rule(&self, rule: ValidationRuleId) -> impl Iterator<Item = &ValidationIssue> {
self.issues.iter().filter(move |i| i.rule_id == rule)
}
}
#[must_use]
pub fn validate_intervals(
intervals: &[MeterInterval],
config: &ValidationConfig,
) -> ValidationResult {
let mut issues: Vec<ValidationIssue> = Vec::new();
if intervals.is_empty() {
if let (Some((from, to)), Some(secs)) = (config.period, config.expected_interval_secs)
&& to > from
&& secs > 0
{
issues.push(gap_issue(from, to, secs, None));
}
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]),
);
}
issues.extend(per_interval_rules(intervals, &order, config));
issues.extend(outlier_rule(intervals, &order, config));
issues.extend(gap_rules(intervals, &order, config));
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), i.rule_id.code()));
ValidationResult { issues }
}
fn per_interval_rules(
intervals: &[MeterInterval],
order: &[usize],
config: &ValidationConfig,
) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
let mut zero_run = 0usize;
for (pos, &idx) in order.iter().enumerate() {
let iv = &intervals[idx];
if config.negative_energy_is_error && iv.value < Decimal::ZERO {
issues.push(
ValidationIssue::new(
ValidationRuleId::NegativeEnergy,
ValidationSeverity::Error,
format!("negative energy {} kWh at {}", iv.value, 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::ImplausiblePower,
ValidationSeverity::Error,
format!(
"average power {power_kw} kW over {}–{} exceeds the plant capacity \
{cap_kw} kW",
iv.from, iv.to
),
)
.at(idx, iv),
);
}
if iv.value.is_zero() {
zero_run += 1;
if zero_run == config.zero_run_threshold
&& config.zero_run_threshold > 0
&& let Some(start) = (pos + 1).checked_sub(config.zero_run_threshold)
{
let start_idx = order[start];
issues.push(
ValidationIssue::new(
ValidationRuleId::SuspiciousZeroRun,
ValidationSeverity::Warning,
format!(
"{} consecutive zero intervals from {}",
config.zero_run_threshold, intervals[start_idx].from
),
)
.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_length_secs(iv, expected_secs) {
issues.push(
ValidationIssue::new(
ValidationRuleId::InconsistentIntervalLength,
ValidationSeverity::Warning,
format!(
"expected a {expected_secs} s interval, got {actual_secs} s at {}",
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 {} — an Ersatzwert is required",
iv.quality, iv.from
),
)
.at(idx, iv),
);
}
}
issues
}
fn expected_length_secs(iv: &MeterInterval, expected_secs: u32) -> i64 {
const ONE_DAY: u32 = 86_400;
if expected_secs != ONE_DAY {
return i64::from(expected_secs);
}
let local = iv.from.to_timezone(timezones::db::europe::BERLIN);
if local.time() != time::Time::MIDNIGHT {
return i64::from(ONE_DAY);
}
crate::calendar::day_length(local.date()).whole_seconds()
}
fn outlier_rule(
intervals: &[MeterInterval],
order: &[usize],
config: &ValidationConfig,
) -> Vec<ValidationIssue> {
let Some(sigma) = config.outlier_sigma.filter(|s| s.is_finite() && *s > 0.0) else {
return Vec::new();
};
let k = config.outlier_window;
if k == 0 || order.len() <= k * 2 {
return Vec::new();
}
let values: Vec<f64> = order
.iter()
.map(|&i| intervals[i].value.to_f64().unwrap_or(0.0))
.collect();
crate::quality::hampel_filter_with_floor(&values, k, sigma, config.outlier_min_sigma)
.into_iter()
.map(|pos| {
let idx = order[pos];
let iv = &intervals[idx];
ValidationIssue::new(
ValidationRuleId::StatisticalOutlier,
ValidationSeverity::Warning,
format!(
"{} kWh at {} deviates from its {}-interval neighbourhood by more than \
{sigma} robust sigma",
iv.value,
iv.from,
2 * k + 1
),
)
.at(idx, iv)
})
.collect()
}
fn gap_issue(
from: OffsetDateTime,
to: OffsetDateTime,
expected_secs: u32,
index: Option<usize>,
) -> ValidationIssue {
let gap_secs = (to - from).whole_seconds();
let count = gap_secs / i64::from(expected_secs);
let mut issue = ValidationIssue::new(
ValidationRuleId::GapDetected,
ValidationSeverity::Error,
format!("gap of {count} interval(s) between {from} and {to} — Ersatzwerte required"),
)
.anchored_at(from);
issue.interval_index = index;
issue
}
fn gap_rules(
intervals: &[MeterInterval],
order: &[usize],
config: &ValidationConfig,
) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
let mut max_end: Option<(OffsetDateTime, usize)> = None;
for &idx in order {
let iv = &intervals[idx];
if let Some((end, prev_idx)) = max_end
&& iv.from < end
{
let prev = &intervals[prev_idx];
issues.push(
ValidationIssue::new(
ValidationRuleId::OverlapDetected,
ValidationSeverity::Error,
format!(
"interval [{}, {}) overlaps [{}, {})",
iv.from, iv.to, prev.from, prev.to
),
)
.at(idx, iv),
);
}
if max_end.is_none_or(|(end, _)| iv.to > end) {
max_end = Some((iv.to, idx));
}
}
let Some(expected_secs) = config.expected_interval_secs.filter(|s| *s > 0) else {
return issues;
};
let step = i64::from(expected_secs);
for window in order.windows(2) {
let (a, b) = (&intervals[window[0]], &intervals[window[1]]);
if (b.from - a.to).whole_seconds() >= step {
issues.push(gap_issue(a.to, b.from, expected_secs, Some(window[1])));
}
}
if let Some((period_from, period_to)) = config.period {
let first = &intervals[order[0]];
let last = &intervals[*order.last().expect("non-empty")];
if (first.from - period_from).whole_seconds() >= step {
issues.push(gap_issue(
period_from,
first.from,
expected_secs,
Some(order[0]),
));
}
if (period_to - last.to).whole_seconds() >= step {
issues.push(gap_issue(last.to, period_to, expected_secs, None));
}
}
issues
}
fn detect_dst_ambiguity(intervals: &[&MeterInterval]) -> Vec<ValidationIssue> {
let (Some(first), Some(last)) = (intervals.first(), intervals.last()) else {
return Vec::new();
};
let local_day = crate::calendar::local_day(first.from);
if crate::calendar::day_kind(local_day) != crate::calendar::DayKind::LongDay {
return Vec::new();
}
let Some(transition) = crate::calendar::dst_transition_utc(local_day) else {
return Vec::new();
};
let window_start = transition - Duration::hours(1);
let window_end = transition + Duration::hours(1);
if first.from > window_start || last.to < window_end {
return Vec::new();
}
let covered: i64 = intervals
.iter()
.map(|iv| {
let from = iv.from.max(window_start);
let to = iv.to.min(window_end);
(to - from).whole_seconds().max(0)
})
.sum();
const TWO_HOURS: i64 = 2 * 3600;
if covered >= TWO_HOURS {
return Vec::new();
}
vec![
ValidationIssue::new(
ValidationRuleId::DstAmbiguity,
ValidationSeverity::Error,
format!(
"local day {local_day} repeats 02:00–03:00, so {window_start} … {window_end} \
holds two passes of it — but the series covers only {covered} s of that \
window. The repeated hour was collapsed, so an hour of energy is missing \
and the surviving intervals are ambiguous between the two passes."
),
)
.anchored_at(window_start),
]
}
#[cfg(test)]
mod v07_tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn qh(start: OffsetDateTime, n: i64) -> Vec<MeterInterval> {
(0..n)
.map(|i| {
let from = start + Duration::minutes(15 * i);
MeterInterval {
from,
to: from + Duration::minutes(15),
value: dec!(1.0),
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
fn detect(intervals: &[MeterInterval]) -> Vec<ValidationIssue> {
detect_dst_ambiguity(&intervals.iter().collect::<Vec<_>>())
}
#[test]
fn a_complete_25_hour_fall_back_day_is_clean() {
assert!(detect(&qh(datetime!(2026-10-24 22:00 UTC), 100)).is_empty());
}
#[test]
fn a_collapsed_repeated_hour_raises_v07() {
let mut day = qh(datetime!(2026-10-24 22:00 UTC), 100);
day.retain(|iv| {
!(iv.from >= datetime!(2026-10-25 1:00 UTC) && iv.from < datetime!(2026-10-25 2:00 UTC))
});
let issues = detect(&day);
assert_eq!(issues.len(), 1, "expected V07: {issues:?}");
assert_eq!(issues[0].rule_id, ValidationRuleId::DstAmbiguity);
assert!(
issues[0].message.contains("3600 s"),
"{}",
issues[0].message
);
}
#[test]
fn an_ordinary_gap_elsewhere_on_the_day_is_not_a_collapsed_hour() {
let mut day = qh(datetime!(2026-10-24 22:00 UTC), 100);
day.retain(|iv| {
!(iv.from >= datetime!(2026-10-25 11:00 UTC)
&& iv.from < datetime!(2026-10-25 11:30 UTC))
});
assert!(
detect(&day).is_empty(),
"a midday gap must not be reported as a collapsed DST hour"
);
let report = validate_intervals(&day, &ValidationConfig::default());
assert_eq!(report.by_rule(ValidationRuleId::GapDetected).count(), 1);
assert_eq!(report.by_rule(ValidationRuleId::DstAmbiguity).count(), 0);
}
#[test]
fn a_truncated_window_across_the_boundary_is_not_flagged() {
assert!(detect(&qh(datetime!(2026-10-25 0:45 UTC), 4)).is_empty());
}
#[test]
fn a_series_that_stops_inside_the_window_is_not_flagged() {
assert!(detect(&qh(datetime!(2026-10-24 22:00 UTC), 14)).is_empty());
}
#[test]
fn an_ordinary_day_raises_nothing() {
assert!(detect(&qh(datetime!(2026-07-14 22:00 UTC), 96)).is_empty());
}
#[test]
fn spring_forward_raises_nothing() {
assert!(detect(&qh(datetime!(2026-03-28 23:00 UTC), 92)).is_empty());
}
#[test]
fn v07_is_emitted_by_validate_intervals() {
let mut day = qh(datetime!(2026-10-24 22:00 UTC), 100);
day.retain(|iv| {
!(iv.from >= datetime!(2026-10-25 1:00 UTC) && iv.from < datetime!(2026-10-25 2:00 UTC))
});
let report = validate_intervals(&day, &ValidationConfig::default());
assert_eq!(report.by_rule(ValidationRuleId::DstAmbiguity).count(), 1);
}
#[test]
fn the_rule_follows_the_tz_database() {
assert_eq!(
crate::calendar::day_kind(date!(2026 - 10 - 25)),
crate::calendar::DayKind::LongDay
);
assert_eq!(
crate::calendar::day_kind(date!(2027 - 10 - 31)),
crate::calendar::DayKind::LongDay
);
let mut day = qh(datetime!(2027-10-30 22:00 UTC), 100);
day.retain(|iv| {
!(iv.from >= datetime!(2027-10-31 1:00 UTC) && iv.from < datetime!(2027-10-31 2:00 UTC))
});
assert_eq!(detect(&day).len(), 1);
}
}