use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::ids::obis::ObisCode;
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::series::{Series, SeriesError};
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
const fn longest_seconds(resolution: Resolution) -> i64 {
match resolution {
Resolution::Day => 25 * 3600,
Resolution::Month => 31 * 86_400 + 3600,
Resolution::Year => 366 * 86_400,
fixed => match fixed.fixed_seconds() {
Some(s) => s as i64,
None => 0,
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterReading {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub at: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub value: Decimal,
pub quality: QualityFlag,
pub obis_code: Option<ObisCode>,
}
impl MeterReading {
#[must_use]
pub const fn measured(at: OffsetDateTime, value: Decimal) -> Self {
Self {
at,
value,
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[must_use]
pub const fn with_obis(mut self, code: ObisCode) -> Self {
self.obis_code = Some(code);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct Rollover {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub previous: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub current: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub register_capacity: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub delta: Decimal,
}
impl Rollover {
#[must_use]
pub fn duration(&self) -> time::Duration {
self.to - self.from
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct Anomaly {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
pub kind: AnomalyKind,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub previous: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub current: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum AnomalyKind {
BackwardsWithoutRegisterWidth,
ImplausibleRollover,
ImplausibleDelta,
ZeroLengthSpan,
NonBillableEndpoint,
}
impl AnomalyKind {
pub const ALL: [Self; 5] = [
Self::BackwardsWithoutRegisterWidth,
Self::ImplausibleRollover,
Self::ImplausibleDelta,
Self::ZeroLengthSpan,
Self::NonBillableEndpoint,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::BackwardsWithoutRegisterWidth => "BACKWARDS_WITHOUT_REGISTER_WIDTH",
Self::ImplausibleRollover => "IMPLAUSIBLE_ROLLOVER",
Self::ImplausibleDelta => "IMPLAUSIBLE_DELTA",
Self::ZeroLengthSpan => "ZERO_LENGTH_SPAN",
Self::NonBillableEndpoint => "NON_BILLABLE_ENDPOINT",
}
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::BackwardsWithoutRegisterWidth => {
"register decreased and no register width was configured to explain a wrap"
}
Self::ImplausibleRollover => {
"register decreased, but reconstructing a wrap implies an implausible consumption"
}
Self::ImplausibleDelta => "the forward difference exceeds the plausible maximum",
Self::ZeroLengthSpan => "two readings share a timestamp, so there is no span",
Self::NonBillableEndpoint => "one endpoint is not billable",
}
}
}
crate::ids::codes::string_codes! {
AnomalyKind;
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct LastgangConfig {
register_digits: Option<u32>,
max_delta: Option<Decimal>,
result_channel: ResultChannel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
#[non_exhaustive]
pub enum ResultChannel {
#[default]
Unchanged,
Derived,
Fixed(ObisCode),
}
impl ResultChannel {
#[must_use]
pub fn label(self, source: Option<ObisCode>) -> Option<ObisCode> {
match self {
Self::Unchanged => source,
Self::Derived => source.map(|c| c.as_lastgang().unwrap_or(c)),
Self::Fixed(code) => Some(code),
}
}
}
impl Default for LastgangConfig {
fn default() -> Self {
Self {
register_digits: None,
max_delta: None,
result_channel: ResultChannel::Unchanged,
}
}
}
impl LastgangConfig {
#[must_use]
pub const fn strom() -> Self {
Self {
register_digits: None,
max_delta: None,
result_channel: ResultChannel::Derived,
}
}
#[must_use]
pub const fn register_digits(mut self, digits: u32) -> Self {
self.register_digits = Some(digits);
self
}
#[must_use]
pub const fn max_delta(mut self, max: Decimal) -> Self {
self.max_delta = Some(max);
self
}
#[must_use]
pub fn capacity_kw(mut self, capacity_kw: Decimal, cadence: Resolution) -> Option<Self> {
let max = capacity_kw
.checked_mul(Decimal::from(longest_seconds(cadence)))?
.checked_div(Decimal::from(3600u32))?;
self.max_delta = Some(max);
Some(self)
}
#[must_use]
pub const fn label(mut self, code: ObisCode) -> Self {
self.result_channel = ResultChannel::Fixed(code);
self
}
#[must_use]
pub const fn channel(mut self, channel: ResultChannel) -> Self {
self.result_channel = channel;
self
}
fn capacity(&self) -> Option<Decimal> {
let digits = self.register_digits?;
if digits > 28 {
return None;
}
(0..digits).try_fold(Decimal::ONE, |acc, _| acc.checked_mul(Decimal::TEN))
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Lastgang {
pub register: Option<ObisCode>,
pub intervals: Vec<MeterInterval>,
pub rollovers: Vec<Rollover>,
pub anomalies: Vec<Anomaly>,
}
impl Lastgang {
#[must_use]
pub fn is_clean(&self) -> bool {
self.anomalies.is_empty() && self.rollovers.is_empty()
}
#[must_use]
pub fn total(&self) -> Option<Decimal> {
self.intervals
.iter()
.try_fold(Decimal::ZERO, |sum, iv| sum.checked_add(iv.value()))
}
pub fn to_series(
&self,
resolution: Resolution,
boundary: DayBoundary,
) -> Result<Series, SeriesError> {
Series::new(resolution, boundary, self.intervals.clone())
}
}
#[must_use]
pub fn to_lastgang(readings: &[MeterReading], config: &LastgangConfig) -> Vec<Lastgang> {
let mut registers: std::collections::BTreeMap<Option<ObisCode>, Vec<&MeterReading>> =
std::collections::BTreeMap::new();
for r in readings {
registers.entry(r.obis_code).or_default().push(r);
}
registers
.into_iter()
.map(|(register, ordered)| difference(register, ordered, config))
.collect()
}
fn difference(
register: Option<ObisCode>,
mut ordered: Vec<&MeterReading>,
config: &LastgangConfig,
) -> Lastgang {
ordered.sort_by_key(|r| r.at);
let capacity = config.capacity();
let mut intervals = Vec::with_capacity(ordered.len().saturating_sub(1));
let mut rollovers = Vec::new();
let mut anomalies = Vec::new();
for pair in ordered.windows(2) {
let (prev, next) = (pair[0], pair[1]);
let anomaly = |kind| Anomaly {
from: prev.at,
to: next.at,
kind,
previous: prev.value,
current: next.value,
};
if next.at == prev.at {
anomalies.push(anomaly(AnomalyKind::ZeroLengthSpan));
continue;
}
if !prev.quality.is_billable() || !next.quality.is_billable() {
anomalies.push(anomaly(AnomalyKind::NonBillableEndpoint));
continue;
}
let Some(straight) = next.value.checked_sub(prev.value) else {
anomalies.push(anomaly(AnomalyKind::ImplausibleDelta));
continue;
};
let (delta, wrapped) = if straight >= Decimal::ZERO {
(straight, None)
} else {
let Some(cap) = capacity else {
anomalies.push(anomaly(AnomalyKind::BackwardsWithoutRegisterWidth));
continue;
};
let Some(reconstructed) = cap
.checked_sub(prev.value)
.and_then(|d| d.checked_add(next.value))
else {
anomalies.push(anomaly(AnomalyKind::ImplausibleRollover));
continue;
};
if reconstructed < Decimal::ZERO || prev.value >= cap {
anomalies.push(anomaly(AnomalyKind::ImplausibleRollover));
continue;
}
(reconstructed, Some(cap))
};
if let Some(max) = config.max_delta
&& delta > max
{
anomalies.push(anomaly(if wrapped.is_some() {
AnomalyKind::ImplausibleRollover
} else {
AnomalyKind::ImplausibleDelta
}));
continue;
}
if let Some(register_capacity) = wrapped {
rollovers.push(Rollover {
from: prev.at,
to: next.at,
previous: prev.value,
current: next.value,
register_capacity,
delta,
});
}
intervals.extend(
MeterInterval::build(
prev.at,
next.at,
delta,
prev.quality.worse_of(next.quality),
config.result_channel.label(next.obis_code),
)
.ok(),
);
}
Lastgang {
register,
intervals,
rollovers,
anomalies,
}
}
#[must_use]
pub fn detect_reading_cadence(readings: &[MeterReading]) -> Option<Resolution> {
if readings.len() < 2 {
return None;
}
let mut instants: Vec<OffsetDateTime> = readings.iter().map(|r| r.at).collect();
instants.sort_unstable();
let mut gaps: Vec<i64> = instants
.windows(2)
.map(|w| (w[1] - w[0]).whole_seconds())
.filter(|&g| g > 0)
.collect();
if gaps.is_empty() {
return None;
}
gaps.sort_unstable();
Resolution::from_observed_seconds(gaps[gaps.len() / 2])
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ReadingError {
#[error("the end reading at {end} is not after the start reading at {start}")]
Reversed {
start: OffsetDateTime,
end: OffsetDateTime,
},
#[error("the readings are on different registers ({start:?} and {end:?})")]
ChannelMismatch {
start: Option<ObisCode>,
end: Option<ObisCode>,
},
#[error(transparent)]
Anomaly(Anomaly),
}
pub fn consumption_between(
start: &MeterReading,
end: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, ReadingError> {
if end.at <= start.at {
return Err(ReadingError::Reversed {
start: start.at,
end: end.at,
});
}
if start.obis_code != end.obis_code {
return Err(ReadingError::ChannelMismatch {
start: start.obis_code,
end: end.obis_code,
});
}
let mut lastgang = difference(start.obis_code, vec![start, end], config);
match lastgang.anomalies.pop() {
Some(anomaly) => Err(ReadingError::Anomaly(anomaly)),
None => Ok(lastgang
.intervals
.first()
.map_or(Decimal::ZERO, MeterInterval::value)),
}
}
impl std::fmt::Display for Anomaly {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} between {} ({}) and {} ({})",
self.kind.description(),
self.from,
self.previous,
self.to,
self.current
)
}
}
impl std::error::Error for Anomaly {}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
fn zsg(values: &[Decimal]) -> Vec<MeterReading> {
values
.iter()
.enumerate()
.map(|(i, &value)| {
MeterReading::measured(
datetime!(2026-06-01 0:00 UTC) + Duration::minutes(i as i64 * 15),
value,
)
})
.collect()
}
#[test]
fn consumption_between_refuses_two_registers() {
let ht: ObisCode = "1-0:1.8.1".parse().unwrap();
let nt: ObisCode = "1-0:1.8.2".parse().unwrap();
let t = datetime!(2026-06-01 0:00 UTC);
let start = MeterReading::measured(t, dec!(1000)).with_obis(ht);
let end = MeterReading::measured(t + Duration::days(30), dec!(1200)).with_obis(nt);
assert_eq!(
consumption_between(&start, &end, &LastgangConfig::strom()),
Err(ReadingError::ChannelMismatch {
start: Some(ht),
end: Some(nt),
})
);
let end_ht = MeterReading::measured(t + Duration::days(30), dec!(1200)).with_obis(ht);
assert_eq!(
consumption_between(&start, &end_ht, &LastgangConfig::strom()),
Ok(dec!(200))
);
}
#[test]
fn the_lastgang_sums_to_the_register_difference() {
let readings = zsg(&[dec!(1000), dec!(1002.5), dec!(1004.8), dec!(1007)]);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert_eq!(result.intervals.len(), 3, "n readings give n−1 intervals");
assert_eq!(result.total(), Some(dec!(7)), "1007 − 1000");
assert_eq!(result.intervals[0].value(), dec!(2.5));
assert_eq!(result.intervals[1].value(), dec!(2.3));
assert_eq!(result.intervals[2].value(), dec!(2.2));
assert!(result.is_clean());
}
#[test]
fn intervals_tile_the_reading_timestamps() {
let readings = zsg(&[dec!(0), dec!(1), dec!(2), dec!(3)]);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert_eq!(result.intervals[0].from(), readings[0].at);
for pair in result.intervals.windows(2) {
assert_eq!(pair[0].to(), pair[1].from());
}
assert_eq!(result.intervals.last().unwrap().to(), readings[3].at);
}
#[test]
fn fewer_than_two_readings_yield_nothing() {
assert!(to_lastgang(&[], &LastgangConfig::strom()).is_empty());
let one = zsg(&[dec!(1000)]);
let result = to_lastgang(&one, &LastgangConfig::strom()).remove(0);
assert!(result.intervals.is_empty());
assert!(result.is_clean(), "one reading is short, not corrupt");
}
#[test]
fn readings_are_sorted_before_differencing() {
let ordered = zsg(&[dec!(1000), dec!(1002.5), dec!(1004.8)]);
let mut shuffled = ordered.clone();
shuffled.reverse();
let a = to_lastgang(&ordered, &LastgangConfig::strom()).remove(0);
let b = to_lastgang(&shuffled, &LastgangConfig::strom()).remove(0);
assert_eq!(a.intervals, b.intervals);
assert!(
b.is_clean(),
"reordering is not corruption: {:?}",
b.anomalies
);
}
#[test]
fn a_six_digit_register_wrap_is_reconstructed() {
let readings = vec![
MeterReading::measured(datetime!(2026-06-01 0:00 UTC), dec!(999998.5)),
MeterReading::measured(datetime!(2026-06-01 0:15 UTC), dec!(1.5)),
];
let cfg = LastgangConfig::strom().register_digits(6);
let result = to_lastgang(&readings, &cfg).remove(0);
assert_eq!(result.intervals[0].value(), dec!(3.0));
assert_eq!(result.rollovers.len(), 1);
assert!(result.anomalies.is_empty());
let rollover = &result.rollovers[0];
assert_eq!(rollover.register_capacity, dec!(1000000));
assert_eq!(rollover.delta, dec!(3.0));
assert_eq!(rollover.from, datetime!(2026-06-01 0:00 UTC));
assert_eq!(rollover.to, datetime!(2026-06-01 0:15 UTC));
assert_eq!(rollover.duration(), Duration::minutes(15));
assert!(
!result.is_clean(),
"a wrap is explained, but still reported"
);
}
#[test]
fn a_backwards_step_without_a_width_is_an_anomaly() {
let readings = zsg(&[dec!(999998.5), dec!(1.5)]);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert!(result.intervals.is_empty(), "no value is invented");
assert_eq!(
result.anomalies[0].kind,
AnomalyKind::BackwardsWithoutRegisterWidth
);
}
#[test]
fn an_implausible_wrap_is_rejected_rather_than_billed() {
let readings = vec![
MeterReading::measured(datetime!(2026-06-01 0:00 UTC), dec!(800000)),
MeterReading::measured(datetime!(2026-06-01 0:15 UTC), dec!(0)),
];
let cfg = LastgangConfig::strom()
.register_digits(6)
.max_delta(dec!(7.5));
let result = to_lastgang(&readings, &cfg).remove(0);
assert!(result.intervals.is_empty());
assert!(result.rollovers.is_empty());
assert_eq!(result.anomalies[0].kind, AnomalyKind::ImplausibleRollover);
let uncapped =
to_lastgang(&readings, &LastgangConfig::strom().register_digits(6)).remove(0);
assert_eq!(uncapped.intervals[0].value(), dec!(200000));
}
#[test]
fn an_implausible_forward_delta_is_refused() {
let readings = zsg(&[dec!(1000), dec!(9000)]);
let cfg = LastgangConfig::strom().max_delta(dec!(7.5));
let result = to_lastgang(&readings, &cfg).remove(0);
assert!(result.intervals.is_empty());
assert_eq!(result.anomalies[0].kind, AnomalyKind::ImplausibleDelta);
}
#[test]
fn the_delta_cap_can_come_from_a_connection_capacity() {
let cfg = LastgangConfig::strom()
.capacity_kw(dec!(30), Resolution::QUARTER_HOUR)
.expect("no overflow");
assert_eq!(cfg.max_delta, Some(dec!(7.5)));
let hourly = LastgangConfig::strom()
.capacity_kw(dec!(30), Resolution::Hour)
.expect("no overflow");
assert_eq!(hourly.max_delta, Some(dec!(30)));
let readings = zsg(&[dec!(0), dec!(8)]);
assert!(!to_lastgang(&readings, &cfg).remove(0).anomalies.is_empty());
assert!(to_lastgang(&readings, &hourly).remove(0).is_clean());
}
#[test]
fn a_reading_wider_than_the_register_is_not_a_wrap() {
let readings = zsg(&[dec!(50000), dec!(10)]);
let cfg = LastgangConfig::strom().register_digits(4); let result = to_lastgang(&readings, &cfg).remove(0);
assert_eq!(result.anomalies[0].kind, AnomalyKind::ImplausibleRollover);
}
#[test]
fn the_worse_endpoint_quality_wins() {
let mut readings = zsg(&[dec!(0), dec!(2), dec!(4)]);
readings[1].quality = QualityFlag::Estimated;
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert_eq!(result.intervals[0].quality(), QualityFlag::Estimated);
assert_eq!(result.intervals[1].quality(), QualityFlag::Estimated);
}
#[test]
fn a_non_billable_endpoint_yields_no_interval() {
let mut readings = zsg(&[dec!(0), dec!(2), dec!(4)]);
readings[1].quality = QualityFlag::Faulty;
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert!(
result.intervals.is_empty(),
"both spans touch the bad reading"
);
assert_eq!(result.anomalies.len(), 2);
assert!(
result
.anomalies
.iter()
.all(|a| a.kind == AnomalyKind::NonBillableEndpoint)
);
}
#[test]
fn the_result_is_labelled_as_a_lastgang() {
let readings = zsg(&[dec!(0), dec!(2)])
.into_iter()
.map(|r| r.with_obis(ObisCode::STROM_BEZUG_TOTAL))
.collect::<Vec<_>>();
let labelled = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert_eq!(
labelled.intervals[0].obis(),
Some(ObisCode::STROM_BEZUG_LASTGANG)
);
assert!(labelled.intervals[0].obis().unwrap().is_lastgang());
let passthrough = to_lastgang(&readings, &LastgangConfig::default()).remove(0);
assert_eq!(
passthrough.intervals[0].obis(),
Some(ObisCode::STROM_BEZUG_TOTAL)
);
}
#[test]
fn duplicate_timestamps_are_a_zero_length_span() {
let at = datetime!(2026-06-01 0:00 UTC);
let readings = vec![
MeterReading::measured(at, dec!(100)),
MeterReading::measured(at, dec!(102)),
];
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert!(result.intervals.is_empty());
assert_eq!(result.anomalies[0].kind, AnomalyKind::ZeroLengthSpan);
}
#[test]
fn an_unchanged_register_is_zero_consumption() {
let readings = zsg(&[dec!(1000), dec!(1000), dec!(1000)]);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert!(result.is_clean());
assert_eq!(result.total(), Some(Decimal::ZERO));
assert!(result.intervals.iter().all(|iv| iv.value().is_zero()));
}
#[test]
fn an_absurd_register_width_disables_reconstruction() {
let readings = zsg(&[dec!(100), dec!(10)]);
let cfg = LastgangConfig::strom().register_digits(99);
let result = to_lastgang(&readings, &cfg).remove(0);
assert_eq!(
result.anomalies[0].kind,
AnomalyKind::BackwardsWithoutRegisterWidth
);
}
#[test]
fn a_jahresabrechnung_is_two_readings() {
let start = MeterReading::measured(datetime!(2025-01-01 0:00 UTC), dec!(14_230));
let end = MeterReading::measured(datetime!(2026-01-01 0:00 UTC), dec!(17_845));
assert_eq!(
consumption_between(&start, &end, &LastgangConfig::default()).unwrap(),
dec!(3615)
);
}
#[test]
fn consumption_between_reports_the_anomaly_rather_than_a_number() {
let start = MeterReading::measured(datetime!(2025-01-01 0:00 UTC), dec!(17_845));
let end = MeterReading::measured(datetime!(2026-01-01 0:00 UTC), dec!(14_230));
let err = consumption_between(&start, &end, &LastgangConfig::default()).unwrap_err();
assert!(matches!(
&err,
ReadingError::Anomaly(a) if a.kind == AnomalyKind::BackwardsWithoutRegisterWidth
));
assert!(err.to_string().contains("register decreased"), "{err}");
}
#[test]
fn an_anomalous_span_becomes_a_gap_the_substitute_engine_can_fill() {
use crate::time::holiday::Bundesland;
use crate::vee::substitute::{Method, Policy, SubstitutionReason, substitute};
use crate::vee::validation::{Rule, Rules, validate};
use crate::{DayBoundary, Resolution};
let readings = zsg(&[dec!(1000), dec!(1002), dec!(500), dec!(1006), dec!(1008)]);
let cfg = LastgangConfig::strom()
.capacity_kw(dec!(30), Resolution::QUARTER_HOUR)
.expect("no overflow");
let result = to_lastgang(&readings, &cfg).remove(0);
assert_eq!(
result.anomalies.len(),
2,
"both spans touching the bad value"
);
assert_eq!(
result.anomalies[1].kind,
AnomalyKind::ImplausibleDelta,
"the recovery step is as implausible as the drop"
);
assert_eq!(result.intervals.len(), 2);
let series = result
.to_series(Resolution::QUARTER_HOUR, DayBoundary::Strom)
.unwrap();
let hour = DayBoundary::Strom
.bucket(datetime!(2026-06-01 0:00 UTC), Resolution::Hour)
.unwrap();
let report = validate(
&series,
&Rules::strom(hour, datetime!(2026-07-01 0:00 UTC), None),
);
assert_eq!(
report.by_rule(Rule::Gap).count(),
1,
"{:?}",
report.findings
);
let policy = Policy::strom(Bundesland::Be, SubstitutionReason::ImplausibleValue);
let filled = substitute(&series, &report, &policy).unwrap();
assert_eq!(filled.series.len(), 4);
assert_eq!(filled.substitutes.len(), 2);
assert!(
filled
.substitutes
.iter()
.all(|s| s.method == Method::Interpolation)
);
}
#[test]
fn anomaly_metadata_is_complete() {
for kind in AnomalyKind::ALL {
assert!(!kind.description().is_empty(), "{kind:?}");
}
}
}
#[cfg(test)]
mod channel_and_cadence_tests {
use super::*;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
fn zsg(
start: OffsetDateTime,
step: Duration,
n: i64,
code: Option<ObisCode>,
) -> Vec<MeterReading> {
(0..n)
.map(|i| {
let r = MeterReading::measured(
start + step * (i as i32),
dec!(1000) + Decimal::from(i),
);
match code {
Some(c) => r.with_obis(c),
None => r,
}
})
.collect()
}
#[test]
fn a_feed_in_series_is_not_relabelled_as_import() {
let readings = zsg(
datetime!(2026-06-01 0:00 UTC),
Duration::minutes(15),
4,
Some(ObisCode::STROM_EINSPEISUNG_TOTAL),
);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
for iv in &result.intervals {
let code = iv.obis().expect("labelled");
assert_eq!(code, ObisCode::STROM_EINSPEISUNG_LASTGANG);
assert!(code.is_export() && !code.is_import());
assert!(code.is_lastgang());
}
let bezug = zsg(
datetime!(2026-06-01 0:00 UTC),
Duration::minutes(15),
4,
Some(ObisCode::STROM_BEZUG_TOTAL),
);
assert_eq!(
to_lastgang(&bezug, &LastgangConfig::strom())
.remove(0)
.intervals[0]
.obis(),
Some(ObisCode::STROM_BEZUG_LASTGANG)
);
}
#[test]
fn a_register_without_a_lastgang_keeps_its_code() {
for code in [
"1-0:1.8.1".parse::<ObisCode>().unwrap(), ObisCode::GAS_VOLUME_M3, ] {
let readings = zsg(
datetime!(2026-06-01 0:00 UTC),
Duration::minutes(15),
3,
Some(code),
);
let result = to_lastgang(&readings, &LastgangConfig::strom()).remove(0);
assert_eq!(result.intervals[0].obis(), Some(code), "{code}");
}
let bare = zsg(
datetime!(2026-06-01 0:00 UTC),
Duration::minutes(15),
3,
None,
);
assert_eq!(
to_lastgang(&bare, &LastgangConfig::strom())
.remove(0)
.intervals[0]
.obis(),
None
);
}
#[test]
fn the_three_result_channels_do_what_they_say() {
let readings = zsg(
datetime!(2026-06-01 0:00 UTC),
Duration::minutes(15),
3,
Some(ObisCode::STROM_BEZUG_TOTAL),
);
let label =
|cfg: LastgangConfig| to_lastgang(&readings, &cfg).remove(0).intervals[0].obis();
assert_eq!(
label(LastgangConfig::default()),
Some(ObisCode::STROM_BEZUG_TOTAL),
"Unchanged carries the reading's own code"
);
assert_eq!(
label(LastgangConfig::strom()),
Some(ObisCode::STROM_BEZUG_LASTGANG)
);
assert_eq!(
label(LastgangConfig::default().label(ObisCode::GAS_VOLUME_M3)),
Some(ObisCode::GAS_VOLUME_M3),
"Fixed does exactly what it says, including when that is wrong"
);
assert_eq!(ResultChannel::default(), ResultChannel::Unchanged);
assert_eq!(ResultChannel::Derived.label(None), None);
}
#[test]
fn the_cadence_comes_from_the_spacing_of_the_readings() {
let base = datetime!(2026-06-01 0:00 UTC);
for (step, expected) in [
(Duration::minutes(15), Resolution::QUARTER_HOUR),
(Duration::minutes(30), Resolution::HALF_HOUR),
(Duration::hours(1), Resolution::Hour),
] {
assert_eq!(
detect_reading_cadence(&zsg(base, step, 8, None)),
Some(expected),
"{step:?}"
);
}
assert_eq!(detect_reading_cadence(&[]), None);
assert_eq!(
detect_reading_cadence(&zsg(base, Duration::ZERO, 1, None)),
None
);
assert_eq!(
detect_reading_cadence(&zsg(base, Duration::ZERO, 4, None)),
None
);
}
#[test]
fn the_cadence_is_robust_to_gaps_and_disorder() {
let base = datetime!(2026-06-01 0:00 UTC);
let mut readings = zsg(base, Duration::minutes(15), 12, None);
readings.remove(5); assert_eq!(
detect_reading_cadence(&readings),
Some(Resolution::QUARTER_HOUR)
);
readings.reverse();
assert_eq!(
detect_reading_cadence(&readings),
Some(Resolution::QUARTER_HOUR),
"sorted before differencing"
);
}
#[test]
fn a_daily_cadence_is_a_calendar_day() {
let readings: Vec<MeterReading> = (0..6)
.map(|i| {
let day = time::macros::date!(2026 - 10 - 23)
.checked_add(Duration::days(i))
.unwrap();
MeterReading::measured(
crate::time::calendar::DayBoundary::Strom
.day(day)
.unwrap()
.start(),
dec!(1000) + Decimal::from(i),
)
})
.collect();
assert_eq!(
detect_reading_cadence(&readings),
Some(Resolution::Day),
"not a fixed 86 400 s window"
);
let cfg = LastgangConfig::default()
.capacity_kw(dec!(30), Resolution::Day)
.expect("no overflow");
assert_eq!(cfg.max_delta, Some(dec!(750)), "30 kW × 25 h");
let long_day = vec![
MeterReading::measured(
crate::time::calendar::DayBoundary::Strom
.day(time::macros::date!(2026 - 10 - 25))
.unwrap()
.start(),
dec!(0),
),
MeterReading::measured(
crate::time::calendar::DayBoundary::Strom
.day(time::macros::date!(2026 - 10 - 25))
.unwrap()
.end(),
dec!(740),
),
];
assert!(
to_lastgang(&long_day, &cfg).remove(0).is_clean(),
"a 25-hour day at full load must not read as an anomaly"
);
}
#[test]
fn the_two_capacity_ceilings_agree() {
use crate::vee::validation::{Rule, Rules, validate};
use crate::{DayBoundary, Series};
let capacity = dec!(30);
let cfg = LastgangConfig::default()
.capacity_kw(capacity, Resolution::QUARTER_HOUR)
.expect("no overflow");
assert_eq!(cfg.max_delta, Some(dec!(7.5)), "30 kW × 0.25 h");
let over = vec![
MeterReading::measured(datetime!(2026-06-01 0:00 UTC), dec!(0)),
MeterReading::measured(datetime!(2026-06-01 0:15 UTC), dec!(8)),
];
assert!(to_lastgang(&over, &cfg).remove(0).intervals.is_empty());
let already_formed = to_lastgang(&over, &LastgangConfig::default())
.remove(0)
.intervals;
let series =
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, already_formed).unwrap();
let day = DayBoundary::Strom
.day(time::macros::date!(2026 - 06 - 01))
.unwrap();
let report = validate(
&series,
&Rules::strom(day, datetime!(2026-07-01 0:00 UTC), Some(capacity)),
);
assert_eq!(
report.by_rule(Rule::Capacity).count(),
1,
"{:?}",
report.findings
);
}
}