use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::{MeterInterval, QualityFlag};
use crate::obis::ObisCode;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterReading {
pub at: OffsetDateTime,
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))]
pub struct Rollover {
pub at: OffsetDateTime,
pub previous: Decimal,
pub current: Decimal,
pub register_capacity: Decimal,
pub delta: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Anomaly {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub kind: AnomalyKind,
pub previous: Decimal,
pub current: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
#[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 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",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LastgangConfig {
pub register_digits: Option<u32>,
pub max_delta: Option<Decimal>,
pub result_obis: Option<ObisCode>,
}
impl Default for LastgangConfig {
fn default() -> Self {
Self {
register_digits: None,
max_delta: None,
result_obis: None,
}
}
}
impl LastgangConfig {
#[must_use]
pub const fn strom() -> Self {
Self {
register_digits: None,
max_delta: None,
result_obis: Some(ObisCode::STROM_BEZUG_LASTGANG),
}
}
#[must_use]
pub const fn with_register_digits(mut self, digits: u32) -> Self {
self.register_digits = Some(digits);
self
}
#[must_use]
pub const fn with_max_delta(mut self, max: Decimal) -> Self {
self.max_delta = Some(max);
self
}
#[must_use]
pub fn with_capacity_kw(mut self, capacity_kw: Decimal, interval_secs: u32) -> Self {
let hours = Decimal::from(interval_secs) / Decimal::from(3600u32);
self.max_delta = Some(capacity_kw * hours);
self
}
#[must_use]
pub const fn labelled(mut self, code: ObisCode) -> Self {
self.result_obis = Some(code);
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)]
pub struct Lastgang {
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) -> Decimal {
self.intervals.iter().map(|iv| iv.value).sum()
}
}
#[must_use]
pub fn to_lastgang(readings: &[MeterReading], config: &LastgangConfig) -> Lastgang {
let mut ordered: Vec<&MeterReading> = readings.iter().collect();
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 straight = next.value - prev.value;
let (delta, wrapped) = if straight >= Decimal::ZERO {
(straight, false)
} else {
let Some(cap) = capacity else {
anomalies.push(anomaly(AnomalyKind::BackwardsWithoutRegisterWidth));
continue;
};
let reconstructed = (cap - prev.value) + next.value;
if reconstructed < Decimal::ZERO || prev.value >= cap {
anomalies.push(anomaly(AnomalyKind::ImplausibleRollover));
continue;
}
(reconstructed, true)
};
if let Some(max) = config.max_delta
&& delta > max
{
anomalies.push(anomaly(if wrapped {
AnomalyKind::ImplausibleRollover
} else {
AnomalyKind::ImplausibleDelta
}));
continue;
}
if wrapped {
rollovers.push(Rollover {
at: next.at,
previous: prev.value,
current: next.value,
register_capacity: capacity.unwrap_or(Decimal::ZERO),
delta,
});
}
intervals.push(MeterInterval {
from: prev.at,
to: next.at,
value: delta,
quality: prev.quality.worse_of(next.quality),
obis_code: config.result_obis.or(next.obis_code),
});
}
Lastgang {
intervals,
rollovers,
anomalies,
}
}
pub fn consumption_between(
start: &MeterReading,
end: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
let pair = [start.clone(), end.clone()];
let mut lastgang = to_lastgang(&pair, config);
match lastgang.anomalies.pop() {
Some(anomaly) => Err(anomaly),
None => Ok(lastgang
.intervals
.first()
.map_or(Decimal::ZERO, |iv| iv.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 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());
assert_eq!(result.intervals.len(), 3, "n readings give n−1 intervals");
assert_eq!(result.total(), 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());
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())
.intervals
.is_empty()
);
let one = zsg(&[dec!(1000)]);
let result = to_lastgang(&one, &LastgangConfig::strom());
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());
let b = to_lastgang(&shuffled, &LastgangConfig::strom());
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().with_register_digits(6);
let result = to_lastgang(&readings, &cfg);
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.at, datetime!(2026-06-01 0:15 UTC));
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());
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()
.with_register_digits(6)
.with_max_delta(dec!(7.5));
let result = to_lastgang(&readings, &cfg);
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().with_register_digits(6));
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().with_max_delta(dec!(7.5));
let result = to_lastgang(&readings, &cfg);
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().with_capacity_kw(dec!(30), 900);
assert_eq!(cfg.max_delta, Some(dec!(7.5)));
let hourly = LastgangConfig::strom().with_capacity_kw(dec!(30), 3600);
assert_eq!(hourly.max_delta, Some(dec!(30)));
let readings = zsg(&[dec!(0), dec!(8)]);
assert!(!to_lastgang(&readings, &cfg).anomalies.is_empty());
assert!(to_lastgang(&readings, &hourly).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().with_register_digits(4); let result = to_lastgang(&readings, &cfg);
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());
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());
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());
assert_eq!(
labelled.intervals[0].obis_code,
Some(ObisCode::STROM_BEZUG_LASTGANG)
);
assert!(labelled.intervals[0].obis_code.unwrap().is_lastgang());
let passthrough = to_lastgang(&readings, &LastgangConfig::default());
assert_eq!(
passthrough.intervals[0].obis_code,
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());
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());
assert!(result.is_clean());
assert_eq!(result.total(), 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().with_register_digits(99);
let result = to_lastgang(&readings, &cfg);
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_eq!(err.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::{
FillGapsConfig, IntervalResolution, ValidationConfig, fill_gaps, validate_intervals,
};
let readings = zsg(&[dec!(1000), dec!(1002), dec!(500), dec!(1006), dec!(1008)]);
let cfg = LastgangConfig::strom().with_capacity_kw(dec!(30), 900);
let result = to_lastgang(&readings, &cfg);
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 from = datetime!(2026-06-01 0:00 UTC);
let to = from + Duration::hours(1);
let cfg = ValidationConfig::default().over_period(from, to);
let report = validate_intervals(&result.intervals, &cfg);
assert!(
report
.by_rule(crate::ValidationRuleId::GapDetected)
.next()
.is_some(),
"{:?}",
report.issues
);
let filled = fill_gaps(
&result.intervals,
&FillGapsConfig::new(IntervalResolution::QuarterHour, from, to)
.because(crate::SubstitutionReason::PlausibilityCheckFailed),
);
assert_eq!(filled.intervals.len(), 4);
assert_eq!(filled.substituted_count(), 2);
}
#[test]
fn anomaly_metadata_is_complete() {
for kind in AnomalyKind::ALL {
assert!(!kind.description().is_empty(), "{kind:?}");
}
}
}