metering

Series and readings

MeterInterval and Series, the validated input of the pipeline; Zählerstände differenced into a Lastgang; resampling onto a coarser grid.

MeterInterval: one value, one span, one quality

The energy or volume in [from, to), its QualityFlag, and optionally the OBIS code of its channel. Constructors refuse a reversed or empty span and take the quality explicitly.

QualityFlagBillableMSCONS QTY
Measuredyes220 Wahrer Wert
Substitutedyes67 Ersatzwert
Estimatedyes187 Prognosewert
PreliminaryyesZ18 Vorläufiger Wert
Calculated, Correctedyes— (a Rechenwert or Korrekturgrund is an STS status)
Faultyno20 Nicht verwendbarer Wert
Unknownno— (the status did not arrive)

value is in the channel's unit — kWh for Strom and Wärme, m³ for Gas before conversion and for Wasser.

use metering::{MeterInterval, QualityFlag};
use rust_decimal::dec;
use time::macros::datetime;

let iv = MeterInterval::new(datetime!(2026-06-01 12:00 UTC), datetime!(2026-06-01 12:15 UTC), dec!(2.5), QualityFlag::Measured)
    .unwrap();
assert_eq!(iv.demand_kw(), Some(dec!(10))); // kWh × 3600 ÷ s
assert!(MeterInterval::new(iv.to(), iv.from(), dec!(1), QualityFlag::Measured).is_err());

Series: validated once

Series::new(resolution, boundary, intervals) checks once that intervals are sorted, unique and non-overlapping, each exactly the Resolution bucket laid out from the local day start of the DayBoundary, and of one channel. Gaps are allowed — a missing slot is a validation finding. Each refusal names the instant: SeriesError::Duplicate, Overlap, Misaligned, MixedChannels, OutOfRange.

use metering::series::SeriesError;
use metering::{DayBoundary, MeterInterval, QualityFlag, Resolution, Series};
use rust_decimal::dec;
use time::macros::datetime;

// 12:07 is not on the quarter-hour grid.
let off = MeterInterval::new(datetime!(2026-06-01 12:07 UTC), datetime!(2026-06-01 12:22 UTC), dec!(1), QualityFlag::Measured)
    .unwrap();
let err = Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, vec![off]).unwrap_err();
assert!(matches!(err, SeriesError::Misaligned { .. }));

Resampling sums, and never invents

resample(&series, Resolution) sums onto a coarser grid on the series' own boundary (a gas series onto Gastage). A bucket missing any source slot is left out — a gap, not a short total — so substitute first. A bucket's quality is the worst of its slots.

use metering::prelude::*;
use time::{Duration, macros::date};

let day = DayBoundary::Strom.day(date!(2026 - 10 - 25)).unwrap();
let slots = (0..100)
    .map(|i| MeterInterval::quarter_hour(day.start() + Duration::minutes(15 * i), dec!(0.25), QualityFlag::Measured))
    .collect::<Result<Vec<_>, _>>().unwrap();
let series = Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, slots).unwrap();

let daily = resample(&series, Resolution::Day).unwrap();
assert_eq!(daily.len(), 1); // one 25-hour day
assert_eq!(daily.as_slice()[0].value(), dec!(25));

From Zählerstände to a Lastgang

The gateway delivers a Zählerstandsgang (BNetzA BK6-24-174), which § 2 Satz 1 Nr. 27 MsbG defines as "die Messung einer Reihe viertelstündig ermittelter Zählerstände von elektrischer Arbeit und stündlich ermittelter Zählerstände von Gasmengen".

to_lastgang groups readings by register (OBIS code), sorts them, and returns one Lastgang per register; LastgangConfig::strom() relabels it as the register's Lastgang channel (1-0:1.8.0 → 1-0:1.29.0). A backwards step is a wrap or a meter exchange, so a rollover is reconstructed only with both a register width and a plausibility cap:

use metering::prelude::*;
use time::{Duration, macros::datetime};

let t = datetime!(2026-06-01 0:00 UTC);
let readings: Vec<MeterReading> = [dec!(999998), dec!(1), dec!(4)]
    .into_iter()
    .enumerate()
    .map(|(i, v)| MeterReading::measured(t + Duration::minutes(15 * i as i64), v))
    .collect();

// No width configured: the backwards step is refused, not guessed.
let refused = &to_lastgang(&readings, &LastgangConfig::strom())[0];
assert_eq!(refused.anomalies.len(), 1);
assert_eq!(refused.intervals.len(), 1);

// A six-digit register on a 30 kW connection: the wrap is 3 kWh, plausible.
let config = LastgangConfig::strom()
    .register_digits(6)
    .capacity_kw(dec!(30), Resolution::QUARTER_HOUR)
    .unwrap();
let lastgang = &to_lastgang(&readings, &config)[0];
assert_eq!(lastgang.rollovers.len(), 1);
assert_eq!(lastgang.intervals[0].value(), dec!(3));
assert_eq!(lastgang.total(), Some(dec!(6)));

A difference that cannot be taken emits no interval and an Anomaly; the hole is a gap for validation and substitute. A derived interval's quality is the worse of its two readings.

FunctionDoes
Lastgang::to_series(resolution, boundary)the result as a Series
consumption_between(start, end, &config)two readings far apart (an annual read); refuses reversed arguments and mismatched registers
detect_reading_cadencethe cadence of the readings, for capacity_kw