# ⚡ metering
[](https://crates.io/crates/metering)
[](https://docs.rs/metering)
[](https://github.com/hupe1980/metering/actions/workflows/ci.yml)
[](https://blog.rust-lang.org/)
[](#-license)
**German energy metering domain library** — Europe/Berlin calendar arithmetic,
interval types, gas conversion (incl. G 685 rounding), billing period
aggregation, SLP/RLM/iMSys classification, BDEW 2025 load profiles with
Dynamisierung, Zählzeitdefinition resolution, MsbG rollout obligations,
BSI TR-03109 SMGW lifecycle, virtual meters (§42b EnWG GGV Solarpaket I),
resampling, forecasting with confidence bounds, and Hampel-filter quality scoring.
> 🧊 **Zero I/O** · ⏱️ **no async** · 🕰️ **no clock** · 🔢 **exact decimal quantities**
`metering` is a **standalone, dependency-light domain crate**. It performs no
network or disk access, spawns no runtime, never reads the system clock, and
never represents a metered quantity as a floating-point number. You bring the
data; it does the arithmetic — deterministically, and with the regulatory basis
documented at every call site.
It computes **energy and volume**, not money: there is no currency, price or
tariff-rate type anywhere in the crate. What leaves here is kWh, m³ and kW,
which a billing layer then prices.
---
## 📦 Installation
```bash
cargo add metering
```
or in `Cargo.toml`:
```toml
[dependencies]
metering = "0.16"
```
With `serde` support for all public types:
```toml
[dependencies]
metering = { version = "0.16", features = ["serde"] }
```
**MSRV:** Rust `1.94` (edition 2024). The MSRV is pinned in
[`rust-toolchain.toml`](rust-toolchain.toml) and verified by a dedicated CI lane.
> ⚠️ **0.16 is a breaking release, and one change reaches data at rest.**
> `ObisCode` now writes `1-0:1.8.0` where it wrote `1-0:1.8.0*255`, so persisted
> code strings need a migration — both spellings still parse, so nothing fails to
> read. [CHANGELOG.md](CHANGELOG.md) has the rest, the constant renames and the
> SQL.
---
## 🚀 Quick start
```rust
use metering::{AggregationConfig, MeterInterval, QualityFlag, aggregate};
use rust_decimal::dec;
use time::macros::datetime;
let intervals = vec![MeterInterval {
from: datetime!(2026-06-01 0:00 UTC),
to: datetime!(2026-06-01 0:15 UTC),
value_kwh: dec!(2.345),
quality: QualityFlag::Measured,
obis_code: Some("1-0:1.8.0".parse().unwrap()),
}];
let period = aggregate(&intervals, &AggregationConfig::rlm_strom());
println!("Arbeitsmenge: {} kWh", period.arbeitsmenge_kwh);
println!("Spitzenleistung: {:?} kW", period.spitzenleistung_kw);
println!("Coverage: {:.1} %", period.coverage_pct);
```
---
## 🗓️ Time is Europe/Berlin
The single most consequential thing this crate gets right. German metering
periods are **local calendar periods**, and a German day is not 24 hours long:
| ordinary | 24 h | 96 |
| last Sunday in March (spring forward) | **23 h** | **92** |
| last Sunday in October (fall back) | **25 h** | **100** |
```rust
use metering::{IntervalResolution, calendar};
use time::macros::{date, datetime};
// A day's real length — never assumed, always resolved.
assert_eq!(calendar::intervals_in_day(date!(2026 - 03 - 29), IntervalResolution::QuarterHour), Some(92));
assert_eq!(calendar::intervals_in_day(date!(2026 - 10 - 25), IntervalResolution::QuarterHour), Some(100));
// March 2026 holds 2 972 quarter-hours, not 31 × 96 = 2 976.
assert_eq!(calendar::intervals_in_month(date!(2026 - 03 - 01), IntervalResolution::QuarterHour), Some(2_972));
// A German day starts at 00:00 Berlin — 23:00 UTC the day before, in winter.
assert_eq!(calendar::day_start_utc(date!(2026 - 01 - 15)), datetime!(2026-01-14 23:00 UTC));
assert_eq!(calendar::day_start_utc(date!(2026 - 07 - 15)), datetime!(2026-07-14 22:00 UTC)); // CEST
assert_eq!(calendar::day_length(date!(2026 - 10 - 25)).whole_hours(), 25);
```
Two failure modes this removes from every downstream consumer:
- **Grouping on the UTC date** books the first hour (winter) or two hours
(summer) of every German day into the previous one — wrong daily *and* monthly
totals for every customer on every day, not just at the transitions. A §13
StromNZV Mehr-/Mindermengensaldo computed on UTC months is wrong every month.
- **Assuming 96 intervals per day** raises a false alarm every spring and, worse,
**masks a genuine four-interval gap every autumn** — 96 of an expected 100
looks complete.
`resample()` buckets `Day`/`Month`/`Year` through this module, so
`expected_count` is 92 or 100 on the transition days automatically. Because a
calendar period has no fixed second count, `IntervalResolution::fixed_seconds()`
returns `None` for `Day`, `Month` and `Year` rather than an approximation.
The rules come from the IANA tz database via `time-tz`, so historical
transitions are right too — Germany's 1980 switch was on 6 April, not the last
Sunday in March.
---
## 🕰️ Determinism
**No function in this crate reads the system clock**, the filesystem or the
network. Where a timestamp is needed it is a parameter:
```rust
use metering::{MeasurementSeries, MeasurementSource};
use time::macros::datetime;
let series = MeasurementSeries::new(
"51238696780",
None,
vec![],
MeasurementSource::ManualEntry { operator_id: "ops-1".into(), reason: "correction".into() },
datetime!(2026-01-02 09:30 UTC), // ← supplied, never sampled
);
```
A clock read is ambient state in the same family as I/O: it makes construction
non-deterministic, so two values built from identical inputs are never equal and
no storage layer can write a round-trip test. Callers that *have* a clock pass
`OffsetDateTime::now_utc()`; callers replaying an archive pass the archived
instant and get the archived value back. `just purity` (and a CI job) greps
non-comment source for clock reads, I/O and `unsafe` so the guarantee cannot
regress silently.
---
## 🗂️ Modules at a glance
| `interval` | `MeterInterval`, `QualityFlag`, `Sparte` | Core interval + quality types |
| `calendar` | `day_start_utc()`, `day_length()`, `intervals_in_day()`, `days_between()`, `DayKind` | Europe/Berlin calendar days, months, years — DST-correct interval counts |
| `error` | `ParseError` | The single error type every `FromStr` returns |
| `obis` | `ObisCode` | Typed OBIS codes (IEC 62056-21 / BDEW) with `default_resolution()` |
| `validation` | `validate_intervals()`, `ValidationIssue` (V01–V11) | Gap / overlap / spike (statistical + plant-capacity ceiling) / DST / rollover detection |
| `substitute` | `fill_gaps()`, `SubstituteMethod`, `SubstitutionReason` | § 60 Abs. 2 MsbG Ersatzwertbildung |
| `forecast` | `project_annual_consumption()`, `substitute_values()` | § 60 Abs. 2 MsbG Jahresprognose (with 95 % confidence bounds) + §17 Abs. 2 prior-period gap-fill |
| `resample` | `resample()`, `ResampledBucket` | Down-sample 15-min → hourly / daily / monthly |
| `virtual_meter` | `compute_virtual_meter()`, `AggregationRule` | §42b EnWG GGV: `GgvConstantAllocation` (CCI+ZG6) + `GgvProportionalAllocation` (variable); Residuallast (§42a) |
| `smgw` | `SmgwSession`, `ClsChannel`, `GatewayCertificate` | BSI TR-03109 gateway lifecycle, §14a CLS channels |
| `measurement_point` | `MeasurementPoint`, `MarktRolle`, `EnergyFlow` | MaLo + MeLo + OBIS + role binding |
| `measurement_series` | `MeasurementSeries`, `MeasurementSource`, `ProvenanceEntry` | § 60 Abs. 6 MsbG provenance / explainability |
| `register` | `MeterRegister`, `EnergyDirection`, `RegisterUnit` | HT/NT register + Wandlerfaktor metadata |
| `aggregation` | `aggregate()`, `BillingPeriod`, `HtNtSplit` | § 12 StromNZV Spitzenleistung + HT/NT |
| `classification` | `classify_messtyp()`, `detect_interval_length()`, `Messtyp` | SLP/RLM/iMSys (§3/§ 12 StromNZV, §41a EnWG) |
| `quality` | `score_intervals()`, `QualityGrade` | Hampel-filter quality scoring (A/B/C/F) |
| `demand` | `DemandWindow`, `DemandInterval` | 15-min demand / Spitzenleistung |
| `tariff_window` | `TariffWindow`, `HtNtSchedule` | DST-aware HT/NT window classification |
| `load_profile` | `LoadProfile`, `Dynamization`, `DynamicSlpProfile` | SLP classes incl. BDEW 2025 (H25/G25/L25/P25/S25); Dynamisierungsfunktion (factors 4 dp, result 3 dp) |
| `zaehlzeit` | `Zaehlzeitdefinition`, `ZaehlzeitFenster` | §14a EnWG / UTILTS time-variable register resolution, DST-correct, `split_energy()` |
| `rollout` | `classify_rollout_obligation()`, `ROLLOUT_MILESTONES` | §29 MsbG Pflichteinbaufälle (>6 000 kWh/a, §14a, >7 kW) + §45 Rollout-Fahrplan |
| `conversion` | `gas_m3_to_kwh_hs()`, `G685Rounding` | §25 Nr. 4 MessEV / DVGW G 685 incl. published NB rounding practice (z 4 dp, Hs 3 dp; final rounding configurable) |
| `imbalance` | `compute_imbalance()`, `ImbalanceSaldo` | § 13 StromNZV Mehr-/Mindermengensaldo |
| `resolution` | `IntervalResolution` | Typed interval lengths (15-min / hourly / daily …) |
| `sharing` | §42c EnWG eligibility predicates | Energy-Sharing metering eligibility — pure capability/delivery rules over `Messtyp` and `IntervalResolution` |
| `lifecycle` | `MeterExchangeEvent`, `MeterStatus` | WiM meter exchange domain events |
| `losses` | `network_losses()`, `NetworkLosses` | Netzverluste from Einspeisung vs. Entnahme |
| `power_quality` | `PowerQualityInterval` | Voltage / frequency quality samples alongside energy |
---
## 🧱 Design constraints
| 🧊 **No I/O** | All inputs are passed as arguments. |
| ⏱️ **No async** | Synchronous throughout. |
| 🕰️ **No clock** | No `now_utc()` anywhere; timestamps are parameters. CI-enforced. |
| 🔢 **Exact quantities** | Every metered value is `rust_decimal::Decimal`. `f64` appears only in statistics and coverage — see below. |
| 🎯 **Deterministic** | Same inputs always produce the same output — replayable and cacheable. |
| 🔒 **No `unsafe`** | `#![deny(unsafe_code)]`, also checked by the purity job. |
| 🪶 **Small dependency set** | `rust_decimal`, `time`, `time-tz`, `uuid`, `thiserror` — `serde` optional. |
### 🔢 Where `f64` is and is not
The crate is not float-free, and claiming so would be false. The rule is
**which number reaches a bill**:
| Metered quantity | `Decimal` | `value_kwh`, `arbeitsmenge_kwh`, `spitzenleistung_kw`, `ht_kwh`, gas m³→kWh_Hs, GGV allocations, substitute values |
| Statistic / diagnostic | `f64` | `coverage_pct`, Hampel `hampel_t` / `min_sigma` / MAD, `spike_factor`, forecast confidence bounds |
Every value in the first row is exact decimal arithmetic end to end — no
intermediate is ever converted to `f64` and back. The second row is
deliberately `f64`: a threshold, a percentage and a median-absolute-deviation
are comparisons and diagnostics, not amounts, and a Hampel filter in `Decimal`
would buy nothing but a `sqrt` implementation.
The one place they meet is documented at the call site: the V04 spike rule
converts a value to `f64` only to compare it against the `f64` `spike_factor`.
The comparison decides whether to *flag* an interval; it never alters one.
Forecast confidence bounds are likewise `f64`-derived and explicitly
informational — `projected_annual_kwh` itself stays exact `Decimal`.
---
## 🧩 Core types
### `MeterInterval`
A single timestamped energy reading:
```rust
pub struct MeterInterval {
pub from: OffsetDateTime, // UTC, inclusive
pub to: OffsetDateTime, // UTC, exclusive
pub value_kwh: Decimal,
pub quality: QualityFlag,
pub obis_code: Option<ObisCode>, // parsed, not a string
}
```
`obis_code` is a parsed `ObisCode`, so the same channel identifier has the same
type and value wherever it appears and `MeterInterval::obis_code ==
MeasurementSeries::obis_code` is a comparison rather than a parse that might fail
on data already accepted. Parse at the boundary — `"1-0:1.8.0".parse()?` —
and a malformed code is rejected there, where the message is still available to
report.
`iv.berlin_day()` gives the Europe/Berlin calendar day for grouping.
### `Sparte` and `MeasurementUnit`
```rust
pub enum Sparte { Strom, Gas, Waerme, Wasser }
pub enum MeasurementUnit { KiloWattHour, CubicMetre }
```
A Sparte has **two** units, and conflating them is a correctness bug:
| `Strom` | kWh | kWh | no |
| `Gas` | **m³** | **kWh** | **yes** |
| `Waerme` | kWh | kWh | no |
| `Wasser` | m³ | m³ | no |
A gas meter registers volume; its energy content is derived from Brennwert and
Zustandszahl (`gas_m3_to_kwh_hs`). `requires_conversion()` lets an ingest path
require those parameters before storing a value in an energy column.
`Wasser` is the one Sparte billed in a volume — water has no calorific value, so
the gas conversion does not apply to it. For the heat share of warm water see
[HeizkostenV §9 Abs. 2](#-warm-water--heat-energy-heizkostenv-9-abs-2).
### Wire units vs storage units
Storage is canonical — exactly two units reach a database, so no consumer has to
know the table below exists. The **wire** is liberal, because real devices are:
```rust
MeasurementUnit::parse("MWh") // None — would need rescaling
MeasurementUnit::parse_scaled("MWh") // Some(UnitScale { KiloWattHour, 1000, 1 })
```
| `kWh`, `kWh_th`, `kWh_Hs` | kWh | 1 |
| `Wh` / `MWh` / `GWh` | kWh | 1/1000 · 1000 · 10⁶ |
| `GJ` | kWh | **2500/9** |
| `MJ` | kWh | **5/18** |
| `m³`, `m3`, `cbm` | m³ | 1 |
| `l`, `ltr`, `liter` | m³ | 1/1000 |
**No German law prescribes a unit for heat meters.** MID Annex VI (MI-004) has no
units clause, and EN 1434-1 cl. 6.3.1 permits *"Joules, Watt-hours or decimal
multiples of those units"* — so a GJ meter is exactly as compliant as a kWh one.
German heat meters ship with kWh, MWh or GJ registers depending on the ordered
variant (ista sensonic 3 is sold in both kWh and GJ; Zenner multidata WR3 offers
MJ), and water submeters commonly report litres. The register unit is therefore
**device metadata, not a constant**.
UN/ECE Rec 20 codes are also accepted, and their mnemonics do not follow the unit
symbols:
| `MTQ` | cubic metre | not `M3` |
| `GV` | gigajoule | not `GJ` |
| `3B` | megajoule | `MJ` is not a Rec 20 code |
| `WHR` / `JOU` / `KJO` | watt hour / joule / kilojoule | |
Rec 20 also assigns `GJ` to gram per millilitre. This crate reads `GJ` as
gigajoule, since no Sparte modelled here carries a density; callers emitting Rec 20
codes should send `GV`.
The GJ and MJ factors are held as **exact rationals**, not decimals: 1 GJ is
277.7… kWh, so a `Decimal` factor would lose precision on every reading.
`UnitScale::apply` multiplies before dividing, making `3.6 GJ` exactly `1000 kWh`.
`parse` rejects anything needing a rescale, so a caller must go through
`parse_scaled` to obtain a factor.
### `QualityFlag` (8 variants)
| `Measured` | ✅ | Direct meter reading |
| `Estimated` | ✅ | Prognosewert (valid for Abschlag per § 60 Abs. 2 MsbG) |
| `Substituted` | ✅ | § 60 Abs. 2 MsbG Ersatzwert |
| `Calculated` | ✅ | Derived (e.g. Residuallast) |
| `Corrected` | ✅ | Retroactive correction |
| `Preliminary` | ✅ | Vorläufiger Wert |
| `Faulty` | ❌ | Must not be billed |
| `Unknown` | ❌ | Quality not determinable |
---
## 🔥 Gas conversion: m³ → kWh_Hs
Implements the Brennwertkorrektur formula per **§25 Nr. 4 MessEV** / **DVGW G 685**:
```rust
use metering::gas_m3_to_kwh_hs;
use rust_decimal::dec;
// kWh_Hs = m³ × Hs × Z (rounded to 6 dp)
let kwh = gas_m3_to_kwh_hs(dec!(100), dec!(10.55), dec!(0.9764));
```
`gas_m3_to_kwh_hs_rounded` applies the published Netzbetreiber rounding practice
(`G685Rounding`: z to 4 dp, Hs to 3 dp, final result configurable).
---
## 🚿 Warm water → heat energy (HeizkostenV §9 Abs. 2)
```rust
warm_water_heat_kwh(volume_m3, mean_temp_c, WarmWaterAdjustments::NONE)
warm_water_heat_kwh_unmetered(flaeche_m2, adjustments)
```
```text
Q [kWh/a] = 2.5 × V [m³] × (t_w [°C] − 10) // Satz 2, metered volume
Q [kWh/a] = 32 × A_Wohn [m²] // Satz 4, floor area
```
Both are fallbacks. **§9 Abs. 2 Satz 1 requires a Wärmezähler**; Satz 2 applies
only where measurement is possible *"nur mit einem unzumutbar hohen Aufwand"*, and
Satz 4 only *"in Ausnahmefällen"* where **neither** the heat quantity **nor** the
volume can be measured.
These are *Zahlenwertgleichungen* — numerical-value equations, not dimensionally
consistent — so the constants carry no unit, and they bundle **different** things:
| 2.5 | Erzeugeraufwandszahl, mittlere spezifische Wärmekapazität des Wassers, Wärmeverluste für Warmwasserspeicher, Verteilung einschließlich Zirkulation, Messdatenerhebungen |
| 32 | Nutzwärmebedarf für Warmwasser, Erzeugeraufwandszahl, Messdatenerhebungen — **no** Speicher-, Verteilungs- oder Zirkulationsverluste |
Because the Erzeugeraufwandszahl sits inside both constants, **Q is generator-input
heat, not delivered useful heat**.
`t_w` is *"die gemessene oder geschätzte mittlere Temperatur"* — an estimate is
permitted and no default or cap is prescribed. `A_Wohn` is the *"Wohn- oder
Nutzfläche"*, not living area alone.
### `WarmWaterAdjustments`
| `brennwert_erdgas` | × 1.11 | brennwertbezogene Abrechnung von Erdgas |
| `eigenstaendige_gewerbliche_waermelieferung` | ÷ 1.15 | **eigenständige** gewerbliche Wärmelieferung |
| `monovalente_waermepumpe` | × 0.30 | Betrieb einer monovalenten Wärmepumpe |
A struct of flags rather than an enum: §9 Abs. 2 Satz 6 applies these to the result
of *"den Zahlenwertgleichungen in Satz 2 oder 4"* and does not make them exclusive,
so a heat-pump system under eigenständige gewerbliche Wärmelieferung takes both.
`eigenständig` is a term of art (cf. §1 Abs. 1 Nr. 2); ordinary commercial heat
supply does not qualify.
A warm-water meter therefore carries **two quantities**: the m³ billed as water,
and the kWh this apportions out of the building's heating bill. The metered and
floor-area forms are separate functions rather than one `Option` parameter, because
they are different evidentiary categories.
---
## 🔍 Validation engine (V01–V11)
```rust
use metering::{ValidationConfig, validate_intervals};
let result = validate_intervals(&intervals, &ValidationConfig::default());
println!("Issues: {}", result.issues.len());
println!("Billing blocked: {}", result.billing_block_count());
```
| Gap detected | V01 | Missing intervals between adjacent reads |
| Overlap detected | V02 | Two intervals covering the same timestamp |
| Negative energy | V03 | `value_kwh < 0` for import registers |
| Impossible spike | V04 | `value_kwh > spike_factor × window_median` |
| Suspicious zero run | V05 | Long sequence of zero values |
| Inconsistent interval | V06 | Mixed 15-min and 60-min in same series |
| DST ambiguity | V07 | Potential local-time leak at CEST→CET fall-back |
| Future timestamp | V08 | `from > now` |
| Non-billable quality | V09 | `Faulty` or `Unknown` intervals in billing window |
| Register rollover | V10 | Counter reset without meter-exchange event |
| Unordered series | V11 | Input not ascending by `from` — usually a broken merge upstream |
`validate_intervals` is **order-independent**: the adjacency rules (V01, V02,
V05, V07, V10) are evaluated in timestamp order whatever order you supply, so a
shuffled series no longer produces a cascade of spurious gap and overlap errors.
The disorder itself is reported once as V11 (a warning, not a billing block), and
`interval_index` on every finding still points into **your** slice, not the
internal ordering.
---
## 🩹 § 60 Abs. 2 MsbG substitute value generation
```rust
use metering::{FillGapsConfig, fill_gaps, fill_gaps_with_config, project_annual_consumption};
// Automatic: linear for short gaps, carry-forward for long
let filled = fill_gaps(&intervals, 900, period_from, period_to);
// Prior-period averaging per § 60 Abs. 2 MsbG
let filled = fill_gaps_with_config(
&intervals, 900, period_from, period_to,
&FillGapsConfig::prior_period(prior_week_intervals),
);
// Annual forecast (Jahresprognose) — None for an empty input series
if let Some(forecast) = project_annual_consumption("MALO_ID", &intervals, None) {
println!("Projected annual kWh: {}", forecast.projected_annual_kwh);
// 95 % bounds are diagnostics — `None` with fewer than two observed days
println!("95 % CI: {:?} … {:?}",
forecast.confidence_lower_kwh, forecast.confidence_upper_kwh);
}
```
The projection scales the daily average to the **target year's real length** —
366 days in a leap year, not a flat 365, which would understate a leap-year
Jahresprognose by 0.27 %. Daily sums are grouped by Berlin calendar day,
matching how they are settled.
| `LinearInterpolation` | Short gaps (≤ 3 intervals) with surrounding data |
| `PriorPeriodAverage` | Same time-slot from prior reference week (§17 Abs. 2) |
| `ZeroFill` | Documented plant shutdown — affirmative zero only |
| `LastValueCarryForward` | Conservative fallback |
---
## 📉 Resampling
```rust
use metering::{ResampleConfig, calendar, resample};
// Down-sample 15-min RLM data to Berlin calendar months (MaBiS § 13 StromNZV)
let monthly = resample(&intervals, &ResampleConfig::to_monthly());
for bucket in &monthly {
println!("{}: {} kWh ({} of {} intervals, {:.1} %)",
calendar::local_month(bucket.from),
bucket.total_kwh,
bucket.interval_count,
bucket.expected_count, // 2 972 for March 2026, not 2 976
bucket.coverage_pct());
}
```
Buckets are half-open `[from, to)` UTC instants over **Berlin calendar
periods**; `expected_count` comes from the bucket's real duration, so DST is
handled without the caller knowing it happened. `ResampleConfig` carries both the
source and target resolution as typed `IntervalResolution`s.
---
## ☀️ Virtual meters (§42b EnWG GGV — Solarpaket I)
Both GGV variants compute the tenant's **net grid draw after PV allocation**,
satisfying §42b Abs. 5 EnWG sentence 2: the allocated PV energy can never exceed
the tenant's actual consumption in any 15-minute interval. This is enforced by the
`Pos()` = `max(0, x)` operator per the BDEW "Anwendungshilfe Berechnungsformeln
Solarpaket 1" (v1.0, 25.01.2024).
### Formula overview
**Constant allocation** (BDEW Beispiel 1 — UTILTS CCI+ZG6):
```text
net_grid_draw_i[t] = max(0, tenant_consumption_i[t] - fraction_i × plant_generation[t])
```
**Proportional allocation** (BDEW Beispiel 3 — variable ratio):
```text
ratio_i[t] = tenant_consumption_i[t] / Σ all_tenant_consumption_j[t]
(0 if denominator = 0 — zero-division protected)
net_grid_draw_i[t] = max(0, tenant_consumption_i[t] - ratio_i[t] × plant_generation[t])
```
### Constant allocation (Beispiel 1 — UTILTS CCI+ZG6)
```rust
use metering::{AggregationRule, compute_virtual_meter};
use rust_decimal::dec;
// Tenant receives 10 % of plant generation; result = net grid draw
let rule = AggregationRule::GgvConstantAllocation {
plant_melo_id: "MELO_PLANT".into(),
tenant_melo_id: "MELO_T2".into(),
fraction: dec!(0.10),
};
let net_grid_draw = compute_virtual_meter(&rule, &sources)?;
// Each interval: max(0, tenant_consumption - 0.10 × plant_generation)
// Examples:
// gen=10, consumption=5 → max(0, 5 - 1) = 4 (1 kWh covered by PV)
// gen=10, consumption=0.5 → max(0, 0.5 - 1) = 0 (§42b cap: no negative draw)
```
### Proportional allocation (Beispiel 3 — variable ratio)
```rust
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "MELO_PLANT".into(),
tenant_melo_id: "MELO_T2".into(),
all_tenant_melo_ids: vec!["MELO_T2".into(), "MELO_T3".into()],
};
let net_grid_draw = compute_virtual_meter(&rule, &sources)?;
// ratio = T2_consumption / (T2 + T3); net = max(0, T2 - ratio × generation)
// zero-division protected: if all consumptions are 0 → net = 0
```
`sources` is a `HashMap<String, Vec<MeterInterval>>` mapping MaLo / MeLo ID to its
sorted series. Only timestamps present in **all** required series are emitted
(intersection semantics) — a gap in any source propagates rather than silently
producing a wrong total.
### Energy balance check (plant feed-in)
The residual plant feed-in (grid export from PV) equals:
```text
plant_feedin[t] = plant_generation[t] - Σ (tenant_consumption_i[t] - net_grid_draw_i[t])
= plant_generation[t] - Σ pv_allocated_i[t]
```
Available rules: `Sum`, `Residual` (§42a EEG), `PvSelfConsumption`,
`GgvConstantAllocation`, `GgvProportionalAllocation` (§42b EnWG Solarpaket I).
---
## 🔐 BSI TR-03109 SMGW lifecycle
```rust
let session = SmgwSession { device_id, status, certificates, cls_channels, .. };
// Certificate expiry (BSI TR-03109-4 §6.3 — renew ≥ 30 days before expiry)
let expiring = session.expiring_certificates(today, 30);
// §14a CLS channel compliance
assert!(session.has_section_14a_cls());
// Communication fault detection (triggers § 60 Abs. 2 MsbG substitute)
if session.is_communication_fault(2) { // 2h threshold
// create Sonderablesung reading order
}
```
---
## 🧮 Billing period aggregation
```rust
use metering::{AggregationConfig, aggregate};
let agg = aggregate(&intervals, &AggregationConfig::rlm_strom());
println!("kWh total: {}", agg.arbeitsmenge_kwh);
println!("Spitzenlast: {:?} kW", agg.spitzenleistung_kw);
if let Some(split) = &agg.ht_nt {
println!("kWh HT: {}", split.ht_kwh);
println!("kWh NT: {}", split.nt_kwh);
}
```
### `AggregationConfig` presets
| `rlm_strom()` | RLM | Spitzenleistung § 12 StromNZV, no tariff split |
| `slp_strom()` | SLP | Arbeitsmenge only |
| `rlm_zweitarif()` | RLM | Spitzenleistung + HT/NT split |
| `gas()` | Gas | Single total (m³ → kWh_Hs, no tariff split) |
The HT window is a `TariffWindow`, so **hour and weekday are both read in
Europe/Berlin local time** — `.with_ht_window(TariffWindow::MON_SAT_0600_2200)`
swaps it. Reading the hour locally but the weekday in UTC (as an earlier version
did) misclassifies the hours either side of local midnight at the week
boundaries. For the general §14a EnWG case use `zaehlzeit::Zaehlzeitdefinition`.
---
## 🏷️ SLP/RLM/iMSys classification
```rust
pub fn classify_messtyp(
malo: &MaloId,
jahresverbrauch_kwh: Decimal,
sparte: Sparte,
) -> Messtyp
```
| `Slp` | < 100 MWh/a (Strom) or < 1.500 MWh/a (Gas) | § 2 MsbG |
| `Rlm` | ≥ 100 MWh/a (Strom) or ≥ 1.500 MWh/a (Gas) | § 12 StromNZV |
| `Imsys` | Pflichteinbau iMSys (§41a EnWG) | §41a EnWG |
---
## 🔢 OBIS medium (value group A)
`ObisCode::is_heat`, `is_water` and `is_heat_cost_allocator` follow the
DLMS/COSEM Blue Book media list that OMS Spec Vol. 2 adopts:
| 1 | Electricity | `is_electricity()` | `STROM_BEZUG_TOTAL`, … |
| 4 | Heizkostenverteiler | `is_heat_cost_allocator()` | — |
| 5 | Cooling | `is_heat()` | `KAELTE_ENERGY` |
| 6 | Heat | `is_heat()` | `WAERME_ENERGY` |
| 7 | Gas | `is_gas()` | `GAS_VOLUME_M3` |
| 8 | Cold water | `is_water()` | `WASSER_KALT_VOLUME` |
| 9 | Hot water | `is_water()` | `WASSER_WARM_VOLUME` |
**Heat is A = 6 and cooling A = 5 — both away from the water media.** A = 8 and
A = 9 are cold and hot water, and a Wärmezähler put on A = 8 lands in the wrong
Sparte. This crate shipped that mistake: `WAERME_ENERGY` was `8-0:1.0.0` through
0.14, so a heat register reported `is_water()` and inherited water's daily
default resolution instead of hourly.
**A = 4 is a Heizkostenverteiler, which measures nothing.** An HCA reports
dimensionless *Verbrauchseinheiten* and carries no Eichfrist — HeizkostenV §5
Abs. 1 Satz 3 admits it as an apportionment device precisely because it measures
no physical unit, so an Eichfrist check must skip it rather than treat a missing
date as an expiry.
A test asserts every named constant satisfies the predicate its name implies, so
that mistake cannot come back unnoticed.
---
## 🧭 OBIS value groups C, D and E
C, D and E are read wrong more often than A, because the IEC group *names* do not
say how the German market *assigns* them. The authority is the EDI@Energy
[Codeliste der OBIS-Kennzahlen und Medien](https://www.bundesnetzagentur.de/DE/Beschlusskammern/BK06/BK6_83_Zug_Mess/835_mitteilungen_datenformate/Mitteilung_31/Anlagen/EDIFACT/Codeliste%20der%20OBIS-Kennzahlen%20und%20Medien%202.4b.pdf?__blob=publicationFile&v=1)
(v2.4b), and four rules follow from it:
**Direction is C alone.** §2.1 writes Bezug as `1-b:1.x.y` and Lieferung as
`1-b:2.x.y`, with `x` and `y` explicitly free. So `is_import()` / `is_export()`
test C, never D:
```rust
use metering::ObisCode;
// Zählerstand, Vorschub, Lastgang, Maximum — all Bezug.
for code in ["1-0:1.8.0", "1-0:1.9.0", "1-0:1.29.0", "1-0:1.6.0"] {
assert!(code.parse::<ObisCode>().unwrap().is_import());
}
```
**D is the Messart — which measurement, over which period.** 6 = Maximum,
8 = Zählerstand (Zeitintegral 1), 9 = Vorschub (Zeitintegral 2), 29 = Lastgang
(Zeitintegral 5). Two of these carry different units: `1-0:1.29.0` is the
15-minute energy series in **kWh**, while the peak-demand register billed under
§ 18 Abs. 1 StromNEV is `1-0:1.6.0` in **kW**. Reading one as the other bills an
energy quantity as a power:
```rust
use metering::ObisCode;
assert!(ObisCode::STROM_BEZUG_LASTGANG.is_lastgang()); // 1-0:1.29.0, kWh
assert!(ObisCode::STROM_BEZUG_MAXIMUM.is_maximum()); // 1-0:1.6.0, kW
```
**E = 63 is a fault counter sitting inside the tariff numbering.** Tariffs run
0…9 before 2023-10-01 and 0…62 since; 63 is the Fehlerregister. `tariff_register()`
returns `None` for it, so its contents cannot be summed into an Arbeitsmenge as
though they were a tariff's consumption.
**Reactive energy is C = 3…8, and only for A = 1.** Blindleistung positiv (3),
negativ (4) and the four quadrants Q I…Q IV (5–8). Checking `C ∈ {3, 4}` alone
misses every quadrant register, and checking C without the medium reports gas
volume (`7-0:3.0.0`) as reactive energy.
---
## ⚖️ Mehr-/Mindermengensaldo
```rust
pub fn compute_imbalance(
actual_kwh: Decimal,
contracted_kwh: Decimal,
) -> ImbalanceSaldo
```
`ImbalanceSaldo` carries `mehr_kwh`, `minder_kwh` and the signed
`delta_kwh = actual − contracted`, plus `is_mehr()` / `is_minder()`.
Consuming **above** the profile is a **Minder**menge — the NB supplied the
shortfall and invoices it (LF owes NB). Consuming **below** it is a
**Mehr**menge, which the NB vergütet (NB owes LF).
Basis: **§ 13 StromNZV**.
---
## 📊 Hampel-filter quality scoring
`score_intervals` runs a Hampel filter over a time-ordered slice of intervals
and returns a `QualityReport` (gaps, outliers, zero runs, coverage, grade):
```rust
use metering::{QualityConfig, Sparte, score_intervals};
let report = score_intervals(&intervals, QualityConfig::for_sparte(Sparte::Strom));
println!("Grade: {:?}", report.grade);
println!("Coverage: {:.1} %", report.coverage_pct);
println!("Outliers: {}", report.outlier_intervals.len());
```
### Media-aware thresholds
| `Strom` | 2 | 0.0 |
| `Gas` | 48 | 0.01 |
| `Waerme` | 720 | 0.05 |
| `Wasser` | 720 | 0.001 |
`min_sigma` guards **MAD implosion**: across a flat window the median absolute
deviation is 0, so `t × sigma` is 0 and every nonzero value scores as an outlier.
`hampel_filter_with_floor` exposes the same guard as a primitive.
### `QualityGrade`
| `A` | Clean — within threshold | Billing proceeds normally |
| `B` | Slightly suspicious (≤ 1 gap/outlier, coverage ≥ 99 %) | Logged; billing proceeds |
| `C` | Likely outlier (≤ 3 gaps/outliers, coverage ≥ 95 %) | Manual review / quality warning event |
| `F` | Severe outlier / substituted | `blocks_billing()` → operator alert |
Low-level primitives are exported too: `hampel_filter` (raw outlier indices),
`hampel_filter_with_floor`, and `score_intervals_f64` / `score_intervals_raw`
for callers that already hold `f64` samples (e.g. straight out of a database).
---
## 🔤 String forms — one value, one string
Every type with a string form has **exactly one** of them, shared by `as_str()`,
`Display`, `FromStr` and the `serde` tag. The rule is worth stating because the
alternative fails silently: a value with two spellings produces two database
keys, two map entries and two "distinct" rows that mean the same thing, and
nothing anywhere reports an error.
| `Sparte`, `MeasurementUnit`, `QualityFlag`, `QualityGrade` | the `as_str()` code — `STROM`, `KWH`, `SUBSTITUTED` |
| `ObisCode` | `1-0:1.8.0` — reduced form, `*F` omitted when F is 255 |
| `IntervalResolution` | ISO 8601 duration — `PT15M`, `P1D`, `PT300S` |
```rust
use metering::{ObisCode, QualityFlag, IntervalResolution};
assert_eq!(QualityFlag::Substituted.as_str(), "SUBSTITUTED");
assert_eq!("substituted".parse::<QualityFlag>().unwrap(), QualityFlag::Substituted);
// IntervalResolution uses ISO 8601 durations, so Custom(n) round-trips too.
assert_eq!(IntervalResolution::QuarterHour.to_string(), "PT15M");
assert_eq!("PT900S".parse::<IntervalResolution>().unwrap(), IntervalResolution::QuarterHour);
// ObisCode writes the spelling MSCONS carries and people type.
assert_eq!(ObisCode::STROM_BEZUG_TOTAL.to_string(), "1-0:1.8.0");
```
### Parsing is lenient; writing is not
That is what lets one string be canonical without rejecting real-world input.
`ObisCode` accepts `1-0:1.8.0*255`, leading zeros and surrounding whitespace;
`IntervalResolution` accepts `PT900S` and lower case. Every accepted spelling
maps onto the single canonical output, so `s.parse()?.to_string() == s` holds
for every canonical `s`:
```rust
use metering::ObisCode;
// Four spellings that arrive from four systems — one key comes out.
for raw in ["1-0:1.8.0", "1-0:1.8.0*255", " 1-0:1.8.0 ", "01-00:01.08.00"] {
assert_eq!(ObisCode::normalize(raw).unwrap(), "1-0:1.8.0");
}
```
Use `ObisCode::normalize()` when you hold a raw string (a column, a CSV cell, an
MSCONS segment) and `code.to_string()` when you hold a value. For a system that
insists on the explicit six-group form, `code.to_full_string()` — or
`format!("{code:#}")` — writes `1-0:1.8.0*255`, and parses back to the same
value.
A storage group that carries information is **never** elided: `1-0:1.8.0*1` is a
historical billing-period register, a different channel from `1-0:1.8.0`, and it
keeps its suffix. Eliding it would merge two registers that must stay apart.
[`tests/string_canonicalisation.rs`](tests/string_canonicalisation.rs) holds
stability, totality, idempotence and injectivity under `proptest`, for both the
string and the `serde` form.
A test walks `Sparte::ALL`, `QualityFlag::ALL`, `MeasurementUnit::ALL` and
`LoadProfile::ALL` asserting `from_str(x.as_str()) == x` and that all codes are
distinct, so a new variant cannot ship with a half-wired mapping. Unrecognised
input is a parse **error**, never a silent `Unknown` — the latter is a statement
about the measurement, the former about the message.
Every `FromStr` in the crate returns the same `ParseError`, so a decoder needs
one `?` chain rather than three `map_err` calls:
```rust
use metering::{ObisCode, ParseError, QualityFlag, Sparte};
fn decode(sparte: &str, quality: &str, obis: &str)
-> Result<(Sparte, QualityFlag, ObisCode), ParseError>
{
Ok((sparte.parse()?, quality.parse()?, obis.parse()?))
}
let err = decode("STROM", "MEASURED", "nope").unwrap_err();
assert_eq!(err.type_name(), "ObisCode"); // which column was bad
```
---
## 🔓 Enum exhaustiveness
**Domain enums are exhaustive; only error enums are `#[non_exhaustive]`.** A
deliberate choice, and the opposite of the usual library default.
`#[non_exhaustive]` buys the *library* freedom to add variants without a major
version and charges the *consumer* a wildcard arm. That wildcard is where the
cost lands: when a new `Messtyp`, `SubstituteMethod` or `QualityFlag` appears, a
consumer mapping this vocabulary onto their own storage codes wants their build
to break so a human decides what it means. With a wildcard they get a silent
fallback instead — a reading filed under the wrong Messtyp, a substitute value
attributed to the wrong method. For a crate whose output ends up on an invoice,
a compile error at upgrade time is much the cheaper failure.
Error enums are the opposite case, so `VirtualMeterError` *is* `#[non_exhaustive]`:
a consumer that wildcards an unfamiliar error still reports a failure, which is
correct.
The consequence, stated plainly: **adding a variant to a domain enum is a
breaking change here** and will be released as one. Exhaustive `match` and
iteration over `ALL` are supported patterns.
---
## 🔒 Serde representation stability
With `serde` enabled, the emitted representation — enum tags and struct field
names — is **part of the public API and covered by semver**. Persisting these
values means relying on a wire format, so a rename is a breaking change and will
be released as one.
[`tests/serde_representation.rs`](tests/serde_representation.rs) pins every tag
literally, so the commitment is mechanical rather than a promise:
```rust
assert_eq!(to_string(&QualityFlag::Measured)?, "\"MEASURED\"");
assert_eq!(to_string(&Sparte::Waerme)?, "\"WAERME\"");
assert_eq!(to_string(&ObisCode::WAERME_ENERGY)?, "\"6-0:1.0.0\"");
assert_eq!(to_string(&IntervalResolution::Day)?, "\"P1D\"");
```
**So you do not need to define your own storage codes to insulate yourself from
renames here.** If you would rather anyway, that is a legitimate choice — but
make it deliberately, not as a hedge against an unstated policy.
The two types with a canonical string serialise *as* that string, which makes
them stable for a second, independent reason: `ObisCode` is an IEC 62056
identifier and `IntervalResolution` an ISO 8601 duration. Both are external
standards, so no refactor in this crate can rename them — the same argument that
holds for the `to_iso8601()` / `FromStr` pair holds for the `serde` form.
Adding a **new** variant is not breaking for writers and may ship in a minor
release; it is breaking for older readers — the usual open-enum trade-off.
---
## 🎛️ Feature flags
| `serde` | ❌ | Derive `Serialize`/`Deserialize` on all public types (also enables `rust_decimal/serde`) |
---
## 🧪 Testing
```bash
cargo test --all-features
```
The suite covers: gas conversion (DVGW G 685, incl. the published NB rounding
examples), aggregation (RLM/SLP/Gas), Messtyp classification, imbalance
arithmetic, the V01–V11 validation engine (DST transitions, V07 ambiguity,
plant-capacity ceiling, order-independence), § 60 Abs. 2 MsbG substitute methods and forecast confidence
bounds, resampling, §42b EnWG GGV virtual meters (Beispiel 1 constant +
Beispiel 3 proportional, Pos() cap, zero-division guard), §42a Residuallast,
BSI TR-03109 SMGW + CLS lifecycle, Zählzeitdefinition resolution, MsbG §29/§45
rollout classification, BDEW 2025 profile Dynamisierung, measurement series
provenance, register + ObisCode — plus property tests and four integration
suites:
| [`berlin_calendar.rs`](tests/berlin_calendar.rs) | Every DST transition 2025–2027, day tiling across a full year, 92/100-interval completeness |
| [`serde_representation.rs`](tests/serde_representation.rs) | Every enum tag and struct field name, pinned literally |
| [`string_canonicalisation.rs`](tests/string_canonicalisation.rs) | One value, one string — stability, totality, idempotence, injectivity under `proptest` |
| [`regulatory_showcase.rs`](tests/regulatory_showcase.rs) | End-to-end regulatory scenarios with the exact 2026 DST dates |
| [`readme_samples.rs`](tests/readme_samples.rs) | Every snippet in this file, so the README cannot drift from the code |
### 🛠️ Dev tasks
A [`justfile`](justfile) wraps the common commands ([just](https://just.systems)):
```bash
just # list all recipes
just ci # fmt-check + lint + purity + test + doc + package
just test # cargo test --all-features
just lint # clippy with -D warnings, default + all features
just purity # assert no clock read, no I/O, no unsafe in src/
just msrv # compile on the pinned MSRV
just doc-open # build the docs and open them
```
Or plain cargo:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo doc --no-deps --all-features
```
---
## 📚 Regulatory basis
- **§3, § 12 StromNZV** — SLP/RLM classification thresholds
- **§ 12 StromNZV** — Spitzenleistung definition for RLM
- **§ 60 Abs. 2 MsbG** — Ersatzwertbildung + Jahresprognose (substitute values + annual forecast)
- **§ 60 Abs. 6 MsbG** — 3-year provenance retention (`MeasurementSeries`, `ProvenanceEntry`)
- **§ 13 StromNZV** — Mehr-/Mindermengensaldo
- **§25 Nr. 4 MessEV / DVGW G 685** — Gas Brennwertkorrektur
- **§41a EnWG** — 15-Minuten-Lastgang and iMSys Pflichteinbau
- **§42a/§42b EEG** — Residuallast / GGV community solar virtual meters
- **§42c EnWG** — Energy-Sharing metering eligibility
- **§14a EnWG** — Steuerbare Verbrauchseinrichtungen (CLS channels, Zählzeitdefinition)
- **§29 / §45 MsbG** — iMSys Pflichteinbaufälle and the Rollout-Fahrplan quotas
- **HeizkostenV §9 Abs. 2** — Warmwasser-Wärmemenge Zahlenwertgleichungen
- **BDEW Anwendungshilfe SLP Strom (17.03.2025)** — H25/G25/L25/P25/S25 profiles and Dynamisierung rounding
- **BDEW Anwendungshilfe Berechnungsformeln Solarpaket 1 (v1.0, 25.01.2024)** — GGV allocation examples
- **BSI TR-03109** — Smart Meter Gateway lifecycle and certificates
> ℹ️ This crate implements arithmetic and classification rules derived from the
> sources above. It is **not legal advice** and carries no warranty of regulatory
> compliance — verify against the current Gesetzestext for your use case.
---
## 🤝 Contributing
Issues and pull requests are welcome at
[github.com/hupe1980/metering](https://github.com/hupe1980/metering).
Please make sure `just ci` passes before opening a PR. New regulatory rules should
cite their **Rechtsgrundlage** in a doc comment and come with a test derived from a
published example wherever one exists.
---
## 📄 License
Licensed under either of
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
- MIT license ([LICENSE-MIT](LICENSE-MIT))
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.