use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::interval::{MeterInterval, QualityFlag};
use crate::obis::ObisCode;
use crate::resolution::IntervalResolution;
const fn longest_seconds(resolution: IntervalResolution) -> i64 {
match resolution {
IntervalResolution::Day => 25 * 3600,
IntervalResolution::Month => 31 * 86_400 + 3600,
IntervalResolution::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))]
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))]
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)]
#[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 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::codes::string_codes! {
AnomalyKind;
}
#[derive(Debug, Clone, PartialEq)]
pub struct LastgangConfig {
pub register_digits: Option<u32>,
pub max_delta: Option<Decimal>,
pub result_channel: ResultChannel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
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 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, cadence: IntervalResolution) -> Self {
let hours = Decimal::from(longest_seconds(cadence)) / Decimal::from(3600u32);
self.max_delta = Some(capacity_kw * hours);
self
}
#[must_use]
pub const fn labelled(mut self, code: ObisCode) -> Self {
self.result_channel = ResultChannel::Fixed(code);
self
}
#[must_use]
pub const fn on_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)]
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 {
from: prev.at,
to: 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_channel.label(next.obis_code),
});
}
Lastgang {
intervals,
rollovers,
anomalies,
}
}
#[must_use]
pub fn detect_reading_cadence(readings: &[MeterReading]) -> Option<IntervalResolution> {
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();
IntervalResolution::from_observed_seconds(gaps[gaps.len() / 2])
}
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.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());
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), IntervalResolution::QuarterHour);
assert_eq!(cfg.max_delta, Some(dec!(7.5)));
let hourly = LastgangConfig::strom().with_capacity_kw(dec!(30), IntervalResolution::Hour);
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), IntervalResolution::QuarterHour);
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:?}");
}
}
}
#[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());
for iv in &result.intervals {
let code = iv.obis_code.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()).intervals[0].obis_code,
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());
assert_eq!(result.intervals[0].obis_code, 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()).intervals[0].obis_code,
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).intervals[0].obis_code;
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().labelled(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), IntervalResolution::QuarterHour),
(Duration::minutes(30), IntervalResolution::HalfHour),
(Duration::hours(1), IntervalResolution::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(IntervalResolution::QuarterHour)
);
readings.reverse();
assert_eq!(
detect_reading_cadence(&readings),
Some(IntervalResolution::QuarterHour),
"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::calendar::day_start_utc(day),
dec!(1000) + Decimal::from(i),
)
})
.collect();
assert_eq!(
detect_reading_cadence(&readings),
Some(IntervalResolution::Day),
"not a fixed 86 400 s window"
);
let cfg = LastgangConfig::default().with_capacity_kw(dec!(30), IntervalResolution::Day);
assert_eq!(cfg.max_delta, Some(dec!(750)), "30 kW × 25 h");
let long_day = vec![
MeterReading::measured(
crate::calendar::day_start_utc(time::macros::date!(2026 - 10 - 25)),
dec!(0),
),
MeterReading::measured(
crate::calendar::day_end_utc(time::macros::date!(2026 - 10 - 25)),
dec!(740),
),
];
assert!(
to_lastgang(&long_day, &cfg).is_clean(),
"a 25-hour day at full load must not read as an anomaly"
);
}
#[test]
fn the_two_capacity_ceilings_agree() {
use crate::{ValidationConfig, ValidationRuleId, validate_intervals};
let capacity = dec!(30);
let cfg =
LastgangConfig::default().with_capacity_kw(capacity, IntervalResolution::QuarterHour);
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).intervals.is_empty());
let already_formed = to_lastgang(&over, &LastgangConfig::default()).intervals;
let report = validate_intervals(
&already_formed,
&ValidationConfig::default().with_plant_capacity_kw(capacity),
);
assert_eq!(
report.by_rule(ValidationRuleId::ImplausiblePower).count(),
1,
"{:?}",
report.issues
);
}
}