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,
}
impl ValidationSeverity {
pub const ALL: [Self; 3] = [Self::Info, Self::Warning, Self::Error];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "INFO",
Self::Warning => "WARNING",
Self::Error => "ERROR",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ValidationRuleId {
#[cfg_attr(feature = "serde", serde(rename = "V01"))]
GapDetected,
#[cfg_attr(feature = "serde", serde(rename = "V02"))]
OverlapDetected,
#[cfg_attr(feature = "serde", serde(rename = "V03"))]
NegativeEnergy,
#[cfg_attr(feature = "serde", serde(rename = "V04"))]
StatisticalOutlier,
#[cfg_attr(feature = "serde", serde(rename = "V05"))]
SuspiciousZeroRun,
#[cfg_attr(feature = "serde", serde(rename = "V06"))]
InconsistentIntervalLength,
#[cfg_attr(feature = "serde", serde(rename = "V07"))]
DstAmbiguity,
#[cfg_attr(feature = "serde", serde(rename = "V08"))]
FutureTimestamp,
#[cfg_attr(feature = "serde", serde(rename = "V09"))]
NonBillableQuality,
#[cfg_attr(feature = "serde", serde(rename = "V11"))]
UnorderedSeries,
#[cfg_attr(feature = "serde", serde(rename = "V12"))]
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 enabling_field(self) -> Option<&'static str> {
match self {
Self::GapDetected | Self::InconsistentIntervalLength => Some("expected_interval_secs"),
Self::StatisticalOutlier => Some("outlier_sigma"),
Self::FutureTimestamp => Some("now"),
Self::ImplausiblePower => Some("max_plant_power_kw"),
Self::OverlapDetected
| Self::NegativeEnergy
| Self::SuspiciousZeroRun
| Self::DstAmbiguity
| Self::NonBillableQuality
| Self::UnorderedSeries => None,
}
}
const fn bit(self) -> u16 {
1 << (self as u16)
}
#[must_use]
pub const fn as_str(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",
}
}
}
crate::codes::string_codes! {
ValidationRuleId;
ValidationSeverity;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct RuleSet(u16);
impl RuleSet {
pub const EMPTY: Self = Self(0);
pub const ALL: Self = {
let mut bits = 0u16;
let mut i = 0;
while i < ValidationRuleId::ALL.len() {
bits |= ValidationRuleId::ALL[i].bit();
i += 1;
}
Self(bits)
};
#[must_use]
pub const fn contains(self, rule: ValidationRuleId) -> bool {
self.0 & rule.bit() != 0
}
#[must_use]
pub const fn with(self, rule: ValidationRuleId) -> Self {
Self(self.0 | rule.bit())
}
#[must_use]
pub const fn without(self, rule: ValidationRuleId) -> Self {
Self(self.0 & !rule.bit())
}
#[must_use]
pub const fn complement(self) -> Self {
Self(Self::ALL.0 & !self.0)
}
#[must_use]
pub const fn intersection(self, other: Self) -> Self {
Self(self.0 & other.0)
}
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[must_use]
pub const fn len(self) -> usize {
self.0.count_ones() as usize
}
#[must_use]
pub const fn is_empty(self) -> bool {
self.0 == 0
}
pub fn iter(self) -> impl Iterator<Item = ValidationRuleId> {
ValidationRuleId::ALL
.into_iter()
.filter(move |r| self.contains(*r))
}
}
impl FromIterator<ValidationRuleId> for RuleSet {
fn from_iter<I: IntoIterator<Item = ValidationRuleId>>(iter: I) -> Self {
iter.into_iter().fold(Self::EMPTY, Self::with)
}
}
impl IntoIterator for RuleSet {
type Item = ValidationRuleId;
type IntoIter = std::vec::IntoIter<ValidationRuleId>;
fn into_iter(self) -> Self::IntoIter {
self.iter().collect::<Vec<_>>().into_iter()
}
}
impl std::fmt::Display for RuleSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return f.write_str("none");
}
let mut first = true;
for rule in self.iter() {
if !first {
f.write_str(", ")?;
}
f.write_str(rule.as_str())?;
first = false;
}
Ok(())
}
}
#[cfg(feature = "serde")]
mod rule_set_serde {
use super::{RuleSet, ValidationRuleId};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
impl Serialize for RuleSet {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.iter().map(ValidationRuleId::as_str))
}
}
impl<'de> Deserialize<'de> for RuleSet {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Vec::<ValidationRuleId>::deserialize(deserializer)?
.into_iter()
.collect())
}
}
}
#[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 at_reference_instant(mut self, now: OffsetDateTime) -> Self {
self.now = Some(now);
self
}
#[must_use]
pub fn over_period(mut self, from: OffsetDateTime, to: OffsetDateTime) -> Self {
self.period = Some((from, to));
self
}
#[must_use]
pub fn enabled_rules(&self) -> RuleSet {
use ValidationRuleId as R;
let mut set = RuleSet::EMPTY
.with(R::OverlapDetected)
.with(R::SuspiciousZeroRun)
.with(R::DstAmbiguity)
.with(R::NonBillableQuality)
.with(R::UnorderedSeries);
if self.negative_energy_is_error {
set = set.with(R::NegativeEnergy);
}
if self.expected_interval_secs.is_some_and(|s| s > 0) {
set = set.with(R::GapDetected).with(R::InconsistentIntervalLength);
}
if self.outlier_sigma.is_some_and(|t| t.is_finite() && t > 0.0) && self.outlier_window > 0 {
set = set.with(R::StatisticalOutlier);
}
if self.now.is_some() {
set = set.with(R::FutureTimestamp);
}
if self.max_plant_power_kw.is_some() {
set = set.with(R::ImplausiblePower);
}
set
}
#[must_use]
pub fn disabled_rules(&self) -> RuleSet {
self.enabled_rules().complement()
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub issues: Vec<ValidationIssue>,
pub evaluated: RuleSet,
}
impl ValidationResult {
#[must_use]
pub fn skipped(&self) -> RuleSet {
self.evaluated.complement()
}
#[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();
let enabled = config.enabled_rules();
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,
evaluated: enabled.intersection(RuleSet::EMPTY.with(ValidationRuleId::GapDetected)),
};
}
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.as_str()));
let mut evaluated = enabled;
if !outlier_window_fits(intervals.len(), config) {
evaluated = evaluated.without(ValidationRuleId::StatisticalOutlier);
}
ValidationResult { issues, evaluated }
}
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_window_fits(len: usize, config: &ValidationConfig) -> bool {
config.outlier_window > 0 && len > config.outlier_window * 2
}
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 !outlier_window_fits(order.len(), config) {
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 message = if count >= 1 {
format!("gap of {count} interval(s) between {from} and {to} — Ersatzwerte required")
} else {
format!(
"{gap_secs} s uncovered between {from} and {to} — shorter than the \
{expected_secs} s grid, so the series is off-grid rather than merely short"
)
};
let mut issue = ValidationIssue::new(
ValidationRuleId::GapDetected,
ValidationSeverity::Error,
message,
)
.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;
};
for window in order.windows(2) {
let (a, b) = (&intervals[window[0]], &intervals[window[1]]);
if b.from > a.to {
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 {
issues.push(gap_issue(
period_from,
first.from,
expected_secs,
Some(order[0]),
));
}
if period_to > last.to {
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 mut issues = Vec::new();
let end = crate::calendar::local_day(last.to);
let mut day = crate::calendar::local_day(first.from);
while day <= end {
if crate::calendar::day_kind(day) == crate::calendar::DayKind::LongDay
&& let Some(issue) = collapsed_repeated_hour(intervals, day)
{
issues.push(issue);
}
let Some(next) = day.next_day() else { break };
day = next;
}
issues
}
fn collapsed_repeated_hour(
intervals: &[&MeterInterval],
local_day: time::Date,
) -> Option<ValidationIssue> {
let (first, last) = (intervals.first()?, intervals.last()?);
let transition = crate::calendar::dst_transition_utc(local_day)?;
let window_start = transition - Duration::hours(1);
let window_end = transition + Duration::hours(1);
if first.from > window_start || last.to < window_end {
return None;
}
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 None;
}
Some(
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 a_collapsed_hour_is_found_anywhere_in_the_series() {
let mut span = qh(datetime!(2026-10-23 22:00 UTC), 96 + 100);
span.retain(|iv| {
!(iv.from >= datetime!(2026-10-25 1:00 UTC) && iv.from < datetime!(2026-10-25 2:00 UTC))
});
let issues = detect(&span);
assert_eq!(
issues.len(),
1,
"expected V07 on the interior day: {issues:?}"
);
assert_eq!(issues[0].rule_id, ValidationRuleId::DstAmbiguity);
assert!(
issues[0].message.contains("2026-10-25"),
"{}",
issues[0].message
);
let report = validate_intervals(&span, &ValidationConfig::default());
assert_eq!(report.by_rule(ValidationRuleId::DstAmbiguity).count(), 1);
assert!(detect(&qh(datetime!(2026-10-23 22:00 UTC), 96 + 100)).is_empty());
}
#[test]
fn each_fall_back_day_is_judged_on_its_own() {
let drop_repeat = |ivs: &mut Vec<MeterInterval>, from: OffsetDateTime| {
ivs.retain(|iv| !(iv.from >= from && iv.from < from + Duration::hours(1)));
};
let mut a = qh(datetime!(2026-10-24 22:00 UTC), 100);
drop_repeat(&mut a, datetime!(2026-10-25 1:00 UTC));
assert_eq!(detect(&a).len(), 1);
let mut b = qh(datetime!(2027-10-30 22:00 UTC), 100);
drop_repeat(&mut b, datetime!(2027-10-31 1:00 UTC));
assert_eq!(detect(&b).len(), 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);
}
}
#[cfg(test)]
mod gap_grid_tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::datetime;
fn iv(from: OffsetDateTime, to: OffsetDateTime) -> MeterInterval {
MeterInterval {
from,
to,
value: dec!(1),
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn a_sub_interval_hole_is_still_a_gap() {
let series = vec![
iv(
datetime!(2026-06-01 0:00 UTC),
datetime!(2026-06-01 0:15 UTC),
),
iv(
datetime!(2026-06-01 0:20 UTC),
datetime!(2026-06-01 0:35 UTC),
),
];
let report = validate_intervals(&series, &ValidationConfig::default());
let gaps: Vec<_> = report.by_rule(ValidationRuleId::GapDetected).collect();
assert_eq!(gaps.len(), 1, "{:?}", report.issues);
assert_eq!(gaps[0].severity, ValidationSeverity::Error);
assert!(gaps[0].message.contains("300 s"), "{}", gaps[0].message);
assert!(gaps[0].message.contains("off-grid"), "{}", gaps[0].message);
assert_eq!(
report
.by_rule(ValidationRuleId::InconsistentIntervalLength)
.count(),
0
);
}
#[test]
fn a_sub_interval_hole_at_the_edges_is_reported_too() {
let series = vec![iv(
datetime!(2026-06-01 0:05 UTC),
datetime!(2026-06-01 0:20 UTC),
)];
let cfg = ValidationConfig::default().over_period(
datetime!(2026-06-01 0:00 UTC),
datetime!(2026-06-01 0:25 UTC),
);
let report = validate_intervals(&series, &cfg);
assert_eq!(
report.by_rule(ValidationRuleId::GapDetected).count(),
2,
"five minutes short at each end: {:?}",
report.issues
);
}
#[test]
fn a_contiguous_series_remains_clean() {
let series: Vec<_> = (0..96)
.map(|i| {
let from = datetime!(2026-06-01 0:00 UTC) + Duration::minutes(15 * i);
iv(from, from + Duration::minutes(15))
})
.collect();
let cfg = ValidationConfig::default().over_period(
datetime!(2026-06-01 0:00 UTC),
datetime!(2026-06-02 0:00 UTC),
);
assert!(
validate_intervals(&series, &cfg).is_clean(),
"{:?}",
validate_intervals(&series, &cfg).issues
);
}
}
#[cfg(test)]
mod rule_set_tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::datetime;
fn day(n: i64) -> Vec<MeterInterval> {
(0..n)
.map(|i| {
let from = datetime!(2026-06-01 0:00 UTC) + Duration::minutes(15 * i);
MeterInterval {
from,
to: from + Duration::minutes(15),
value: dec!(2),
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
#[test]
fn the_per_commodity_defaults_leave_v12_inert_and_say_so() {
let cfg = crate::QualityConfig::for_sparte(crate::Sparte::Strom);
let off = cfg.validation.disabled_rules();
assert!(
off.contains(ValidationRuleId::ImplausiblePower),
"for_sparte supplies no plant capacity, so V12 cannot fire"
);
assert!(
off.contains(ValidationRuleId::FutureTimestamp),
"nor a `now`"
);
assert_eq!(off.to_string(), "V08, V12");
assert_eq!(off.len(), 2);
assert_eq!(
ValidationRuleId::ImplausiblePower.enabling_field(),
Some("max_plant_power_kw")
);
assert_eq!(
ValidationRuleId::FutureTimestamp.enabling_field(),
Some("now")
);
assert_eq!(ValidationRuleId::OverlapDetected.enabling_field(), None);
let armed = cfg.validation.clone().with_plant_capacity_kw(dec!(30));
assert!(
armed
.enabled_rules()
.contains(ValidationRuleId::ImplausiblePower)
);
let report = validate_intervals(&day(96), &armed);
assert!(
report
.evaluated
.contains(ValidationRuleId::ImplausiblePower)
);
assert!(report.is_clean(), "{:?}", report.issues);
}
#[test]
fn every_optional_field_maps_to_the_rules_it_arms() {
use ValidationRuleId as R;
let full = ValidationConfig::default()
.with_plant_capacity_kw(dec!(30))
.at_reference_instant(datetime!(2026-06-02 0:00 UTC));
assert_eq!(full.enabled_rules(), RuleSet::ALL);
assert!(full.disabled_rules().is_empty());
for (mutate, expected) in [
(
Box::new(|c: ValidationConfig| ValidationConfig {
expected_interval_secs: None,
..c
}) as Box<dyn Fn(ValidationConfig) -> ValidationConfig>,
vec![R::GapDetected, R::InconsistentIntervalLength],
),
(
Box::new(|c: ValidationConfig| c.without_outlier_detection()),
vec![R::StatisticalOutlier],
),
(
Box::new(|c: ValidationConfig| ValidationConfig { now: None, ..c }),
vec![R::FutureTimestamp],
),
(
Box::new(|c: ValidationConfig| ValidationConfig {
max_plant_power_kw: None,
..c
}),
vec![R::ImplausiblePower],
),
(
Box::new(|c: ValidationConfig| ValidationConfig {
negative_energy_is_error: false,
..c
}),
vec![R::NegativeEnergy],
),
] {
let off = mutate(full.clone()).disabled_rules();
assert_eq!(
off,
expected.iter().copied().collect::<RuleSet>(),
"expected exactly {expected:?} to go inert"
);
}
}
#[test]
fn the_data_can_disable_a_rule_the_config_armed() {
let cfg = ValidationConfig::default(); assert!(
cfg.enabled_rules()
.contains(ValidationRuleId::StatisticalOutlier)
);
let short = validate_intervals(&day(20), &cfg);
assert!(
!short
.evaluated
.contains(ValidationRuleId::StatisticalOutlier),
"20 intervals cannot fill a 25-point window"
);
assert!(
short
.skipped()
.contains(ValidationRuleId::StatisticalOutlier)
);
let long = validate_intervals(&day(96), &cfg);
assert!(
long.evaluated
.contains(ValidationRuleId::StatisticalOutlier)
);
assert!(
cfg.enabled_rules()
.contains(ValidationRuleId::StatisticalOutlier)
);
}
#[test]
fn an_empty_series_evaluates_almost_nothing() {
let cfg = ValidationConfig::default().over_period(
datetime!(2026-06-01 0:00 UTC),
datetime!(2026-06-02 0:00 UTC),
);
let report = validate_intervals(&[], &cfg);
assert_eq!(
report.evaluated,
RuleSet::EMPTY.with(ValidationRuleId::GapDetected)
);
assert!(report.has_errors());
let blind = ValidationConfig {
expected_interval_secs: None,
..ValidationConfig::default()
};
assert!(validate_intervals(&[], &blind).evaluated.is_empty());
}
#[test]
fn rule_set_is_a_set() {
use ValidationRuleId as R;
let s = RuleSet::EMPTY.with(R::GapDetected).with(R::GapDetected);
assert_eq!(s.len(), 1, "a set holds a rule once");
assert!(s.contains(R::GapDetected) && !s.contains(R::OverlapDetected));
assert_eq!(s.without(R::GapDetected), RuleSet::EMPTY);
assert_eq!(RuleSet::ALL.len(), ValidationRuleId::ALL.len());
assert_eq!(RuleSet::ALL.complement(), RuleSet::EMPTY);
assert_eq!(RuleSet::EMPTY.complement(), RuleSet::ALL);
assert_eq!(RuleSet::EMPTY.to_string(), "none");
assert_eq!(
RuleSet::ALL.to_string(),
"V01, V02, V03, V04, V05, V06, V07, V08, V09, V11, V12"
);
assert_eq!(RuleSet::ALL.iter().collect::<RuleSet>(), RuleSet::ALL);
assert_eq!(
RuleSet::ALL.iter().collect::<Vec<_>>(),
ValidationRuleId::ALL.to_vec()
);
assert_eq!(s.union(RuleSet::ALL), RuleSet::ALL);
assert_eq!(s.intersection(RuleSet::EMPTY), RuleSet::EMPTY);
}
#[test]
fn a_grade_reports_what_it_speaks_for() {
let full = crate::QualityConfig {
validation: ValidationConfig::default()
.with_plant_capacity_kw(dec!(1000))
.at_reference_instant(datetime!(2026-06-02 0:00 UTC)),
..crate::QualityConfig::default()
};
let report = crate::score_intervals(&day(96), &full);
assert_eq!(report.grade, crate::QualityGrade::A);
assert!(report.covers_every_rule(), "{}", report.skipped_rules());
let partial = crate::score_intervals(&day(96), &crate::QualityConfig::default());
assert_eq!(partial.grade, crate::QualityGrade::A);
assert!(!partial.covers_every_rule());
assert_eq!(partial.skipped_rules().to_string(), "V08, V12");
}
}