metering

Validation and substitution

Plausibilisierung and Ersatzwertbildung: the rules, the grade, and the substitution policy — interpolation up to 2 h, comparable days beyond, a register anchor — with their sources and MSCONS codes.

  1. validate(&series, &rules) → Report: findings, the rules that ran, coverage of the declared period, a grade.
  2. substitute(&series, &report, &policy) → Filled: the repaired series, one Substitute per slot, and the spans it refused.

Validation

Rules::strom(period, now, capacity_kw), Rules::gas(…) and Rules::new(sparte, …) are presets. The period is the one the series is declared to cover, so a gap at its head or tail is found.

RuleSeverityCatchesRunsBasis
Gaperrora slot of the period with no valuealwaysMeteringCode 2006 A7.1.2.1/A7.1.2.2
NonBillableerrora Faulty or Unknown valuealwaysA7.1.2.3
Negativeerrora negative quantityon; without_negative()Codeliste OBIS 2.5c § 2.1
Capacityerroraverage power above the plant capacitywith a capacity_kwA7.2
Futureblockinga slot not yet ended at nowalways—
LaengsvergleichblockingΣ load curve ≠ register advance beyond a toleranceopt-in: laengsvergleich(start, end, tol)A7.2; Elexon BSCP502 § 4.1.5
OutsidePeriodwarningdata outside the declared periodalways—
ZeroRunwarningzeros for at least a set durationon; without_zero_run()A7.2
Stalewarninga non-zero value frozen over a windowStrom preset; stale(…)pvanalytics stale_values_diff
Spikewarninga value far from its neighbours (Hampel, MAD floor)on; without_spike()A7.2
Seasonalwarninga value far from the same slot on comparable daysopt-in: seasonal(land, days)A7.2

The basis is VDN MeteringCode 2006, Anlage 7. It names the checks, not their limits, so every threshold is a setter on Rules with a documented preset.

Grade: A no findings, B warnings only, C errors (repairable by substitution), F anything blocking. Report::evaluated lists the rules that ran and skipped() the rest, so "found nothing" and "never looked" differ.

use metering::prelude::*;
use metering::vee::validation::{Grade, Rule};
use time::{Duration, macros::{date, datetime}};

let day = DayBoundary::Strom.day(date!(2026 - 06 - 01)).unwrap();
let slots = (0..96)
    .map(|i| {
        let kwh = if i == 40 { dec!(-0.3) } else { dec!(1.2) };
        MeterInterval::quarter_hour(day.start() + Duration::minutes(15 * i), kwh, QualityFlag::Measured)
    })
    .collect::<Result<Vec<_>, _>>().unwrap();
let series = Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, slots).unwrap();

let report = validate(&series, &Rules::strom(day, datetime!(2026-07-01 0:00 UTC), None));
assert_eq!(report.grade(), Grade::C);
assert_eq!(report.by_rule(Rule::Negative).count(), 1);
assert!(report.skipped().contains(&Rule::Capacity)); // no capacity given
assert!(report.skipped().contains(&Rule::Seasonal)); // opt-in

Report::coverage counts delivered intervals; BillingPeriod::coverage counts billable ones.

Substitution

substitute replaces every slot an error finding covers and nothing else. Each substitute records its method, the MSCONS STS+Z32 method code, the STS+Z40 reason, the gap length, its reference instants, and the original value of a rejected slot.

SituationMethodStromGas
gap ≤ 2 h, validated measured values on both sidesInterpolationZ92Z92
longer gap, or a gap at the edge of the dataVergleichswert — mean of the same slot on the previous same-day-type daysZJ2Z95
gas only, chosen explicitly (Policy::hold)Hold — the last measured value—Z93
a confirmed supply interruption (Policy::outage)Zero——
  • 2-hour line, two methods: MeteringCode 2006 A8.2.2.1 (interpolation from checked values) and A8.2.2.2 (Vergleichswertverfahren); § 55 Abs. 2 Z 1 ElWG (AT) and CPUC VEE Rev. 2.0 § 4.1 draw the same line. Inclusive; Policy::short sets it.
  • Comparable days: the previous days of the same day class in the Land (a holiday counts as a Sunday), three by default (Policy::days), from the series or Policy::history.
  • References are only validated measured values, the window anchored at the gap start.
  • Zeros only for an established outage (A7.1.2.2). Policy::hold refuses any Sparte but gas, the only one with a code for it.
  • No applicable method: the slot is refused with its reason (NoComparableHistory, NoHeldValue, …).

The reason is a SubstitutionReason (STS+Z40, MSCONS MIG 2.5), stated by the caller. A value rejected by a plausibility rule carries ZA1 Messwert unplausibel instead.

The register anchor

Two register readings bracketing the gap (Policy::anchor) rescale the substitutes so the series sums to the register advance exactly — MeteringCode A8.2.2.2 Skalierung, Elexon BSCP502 § 4.2, NL Meetcode § 5.4.3. One cut to SUBSTITUTE_DP; the remainder goes on the last substitute.

use metering::prelude::*;
use metering::time::holiday::Bundesland;
use metering::vee::substitute::SubstitutionReason;
use time::{Duration, macros::{date, datetime}};

let day = DayBoundary::Strom.day(date!(2026 - 06 - 01)).unwrap();
let at = |i: i64| day.start() + Duration::minutes(15 * i);
let slots = (0..96)
    .filter(|i| !(40..44).contains(i)) // one hour missing
    .map(|i| MeterInterval::quarter_hour(at(i), dec!(1), QualityFlag::Measured))
    .collect::<Result<Vec<_>, _>>().unwrap();
let series = Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, slots).unwrap();
let report = validate(&series, &Rules::strom(day, datetime!(2026-07-01 0:00 UTC), None));

// The register advanced 6 kWh over 09:30–11:30 local. Four measured slots hold
// 4 kWh, so the four substitutes are scaled to share the other 2 kWh.
let policy = Policy::strom(Bundesland::Be, SubstitutionReason::CommunicationFailure)
    .anchor(MeterReading::measured(at(38), dec!(100)), MeterReading::measured(at(46), dec!(106)))
    .unwrap();
let filled = substitute(&series, &report, &policy).unwrap();

let substituted: Decimal = filled.substitutes.iter().map(|s| s.value).sum();
assert_eq!(substituted, dec!(2));
assert!(filled.substitutes.iter().all(|s| s.scaled));

A filled series is an ordinary Series: re-validate it, then resample.