⚡ metering
German energy metering domain library for Rust. Europe/Berlin calendar arithmetic — Liefertag and Gastag — Zählerstandsgang → Lastgang, charging sessions and device logs onto the settlement grid, gas m³→kWh_Hs and the SigLinDe gas SLP, Ersatzwertbildung in the market's own code list, a robust validation engine, EN 50160, §14a Modul 3 tariff registers, Benutzungsstundenzahl and Blindmehrarbeit, conservation-checked allocation (§42b EnWG), Redispatch-2.0 Ausfallarbeit (BilAReM), check-digit-validated MaLo-IDs and EICs, and Jahresprognose.
🧊 Zero I/O · ⏱️ no async · 🕰️ no clock · 🔢 exact decimal quantities
Four runtime dependencies — rust_decimal, thiserror, time and time-tz,
whose db feature embeds the IANA tz database rather than reading one from
disk — plus serde behind an optional feature. Nothing in the tree opens a
file, a socket or a system entropy source. time can read the clock; this
crate never calls it, and a CI lane greps to keep it that way.
It computes quantities, not money: what leaves this crate is kWh, m³ and kW, which a billing layer then prices.
📖 Documentation & guides · 🦀 API reference
📦 Installation
# ...or with serde for every public type:
MSRV: Rust 1.94 (edition 2024), pinned in
rust-toolchain.toml and verified by a CI lane.
🚀 Quick start
use ;
use dec;
use datetime;
// One quarter-hour, measured, on the Bezug channel.
let intervals = vec!;
let period = aggregate;
println!;
println!;
println!;
println!;
🗓️ Why this exists: a German day is not 24 hours
The single most consequential thing the crate gets right.
| Day | Length | Quarter-hours |
|---|---|---|
| ordinary | 24 h | 96 |
| last Sunday in March (spring forward) | 23 h | 92 |
| last Sunday in October (fall back) | 25 h | 100 |
use ;
use ;
assert_eq!;
assert_eq!;
// March 2026 holds 2 972 quarter-hours, not 31 × 96 = 2 976.
assert_eq!;
// A German day starts at 23:00 UTC in winter, 22:00 UTC in summer.
assert_eq!;
assert_eq!;
A completeness check built on a hard-coded 96 raises a false alarm every spring and — worse — hides a genuine four-interval gap every autumn, because 96 of an expected 100 looks complete.
...and gas does not start its day at midnight
Gas balances on the Gastag, 06:00 to 06:00 local. That is the same day cut
six hours later, not a different length — so it is a boundary, and
DayBoundary carries it into the places a daily grid is actually built:
use ;
use dec;
use ;
let start = gas_day_start_utc;
let series: =
.map
.collect;
// Two whole Gastage...
let gas_days = resample;
assert_eq!;
assert_eq!;
// ...where the calendar day makes three partial buckets out of the same data.
let calendar_days = resample;
assert_eq!;
The whole pipeline, end to end
examples/pipeline.rs walks one Liefertag from the
Zählerstandsgang the gateway delivered through to the §14a register split —
differencing, validation, Ersatzwertbildung, aggregation and grading — on the
25-hour autumn DST day, with a wrapped register and a corrupt reading planted in
it. It asserts its own invariants, so CI runs it as a test.
🧭 What's in it
| Area | What it does | Guide |
|---|---|---|
| Billing quantities | Arbeitsmenge, Spitzenleistung with the interval it was reached in, the Benutzungsstundenzahl §17 StromNEV bands the Netzentgelt by, Blindmehrarbeit beyond the Freigrenze, and the import/export/undirected balance | → |
| Calendar | Berlin days, months, years and the 06:00 Gastag; DayBoundary carries the choice into resampling and gap filling; DST-correct interval counts; Bundesland holidays |
→ |
| Readings | Zählerstandsgang → Lastgang, register rollover, meter exchange | → |
| Identifiers | MaloId and Eic with their check characters verified at the parse; MeloId (= the Zählpunktbezeichnung); BdewCode Marktpartner-ID; the Regelzone read off a Bilanzierungsgebiet's EIC |
→ |
| Validation | Order-independent rules V01–V12 (V10 retired), Hampel outlier test, A/B/C/F grading, and a RuleSet saying which rules actually ran; DST- and Gastag-aware daily lengths |
→ |
| Ersatzwerte | Four methods, calendar-aware grid, the Vergleichstag matched on weekday or SLP day type, and the market's own 28 Ersatzwert reasons with their MSCONS codes | → |
| Tariff registers | HT/NT and §14a Modul 3 in one mechanism, plus a Modul 3 conformance check for curated DSO calendars | → |
| §14a steering | P_min,14a with the published Gleichzeitigkeitsfaktor table, and the netzwirksamer Leistungsbezug |
→ |
| Gas & units | m³→kWh_Hs, the G 685-3 Zustandszahl and G 685 rounding, the SigLinDe gas SLP, exact-rational unit normalisation | → |
| Power quality | EN 50160 as the statistical test it actually is; VDE-AR-N 4100 Unsymmetrieleistung | → |
| Virtual meters | Sum, Residual, GGV allocation (§42b EnWG) — per tenant and per community, with the §42b Abs. 5 pool ceiling | → |
| Sessions & allocation | A charging session or device log placed on the grid without losing a kWh, and several of them merged onto it; one pool split across many claims with the residual reported; the import/export balance of a bidirectional Zählpunkt | → |
| End to end | The full MSB pipeline as a runnable example | → |
🎯 Scope
This crate computes quantities. Four neighbouring concerns are deliberately out of scope, each with a better home:
| Not here | Why | Where instead |
|---|---|---|
| Money — prices, tariffs, invoices | the output is kWh, m³ and kW | your billing layer |
| EDIFACT / XML market messages | parsing a MSCONS is not arithmetic | mako |
| Fristen — counting Werktage to a deadline | a process-engine concern | your process engine |
| SMGW certificates, device inventory | PKI and asset tracking, not quantities | your device management |
| Levy and subsidy apportionment (MiSpeL, §21 EnFG, §19 EEG) | the quantities exist to size a payment, and the rule is still a bracketed draft | your grid/settlement layer |
Two of those need a word. The crate does carry a German statutory holiday calendar, because SLP day typing and tariff-register classification cannot be done without one — but it counts no business days, so Fristen stay out.
And MiSpeL apportions storage and bidirectional-charging flows so that
Umlageprivilegien and Marktprämien can be computed on them. The arithmetic is
quantity-shaped, but it is defined by the payment rules it feeds, and its
Bekanntgabe is still written [01.10.2026] in square brackets with part of it
waiting on EU state-aid approval.
🧱 Design constraints
- Determinism. No function reads the system clock, the filesystem or the network. Where an instant is needed it is a parameter, so equal inputs give equal outputs. CI enforces this with a grep over non-test code.
- Exact decimals for quantities,
f64only for statistics. The two meet in one place — the outlier rule converts values to run the Hampel filter — and nothing a float touches is written back into a quantity. - "Exact" means no float, and one rounding at most. Sums, differences and
products do not round, so the conservation laws hold to the digit. Division
makes a choice: a quotient a consumer stores is cut to a documented number of
places (
ALLOCATION_DP,SUBSTITUTE_DP,FORECAST_DP, …), and every share multiplies before it divides, becausea ÷ b × crounds first and then scales the error up. - The market's vocabulary, not a parallel one. Where EDI@Energy publishes a
code list, the crate is that list:
SubstitutionReasonis the 28 Statusanlässe ofSTS+Z40,SubstituteMethod::market_codethe Ersatzwertbildungsverfahren ofSTS+Z32(which differ by commodity), andQualityFlag::market_codetheQTYqualifier. Where the market has no code, the answer isNoneand the docs say why. - One value, one string — and one meaning, one value.
ObisCodeandIntervalResolutioneach have exactly one canonical spelling, and two distinct values can never mean the same thing:IntervalResolution::Customrefuses a length that already has a name. Both held by a proptest suite. - Every coded enum carries the whole contract —
ALL,CODES,as_str,Display,FromStr, and aserdetag that is the code. Generate a databaseCHECKconstraint fromCODESand it cannot drift from what the crate writes; one test asserts all six properties for every one of them, and another reads the source so a new enum cannot skip that list. - A clean validation report says which rules ran. Four of the eleven always
run; the other seven hang off six settings, and two of those — a reference
instant and a nameplate capacity — have no default, because they are facts
about the caller's world rather than about a series. So
ValidationResult::evaluatedandValidationConfig::disabled_rules()make "found nothing" and "never looked" distinguishable. - Order in, order out. Every entry point that promises it gives the same answer for a shuffled input, held by proptest. Order dependence shows only on a tie, so the generator draws half its series from a coarse value grid where ties are the norm.
- Nothing created, nothing lost. A session total placed on the grid sums
back to itself; a pool split across claims satisfies
Σ allocated + residual = total. Both identities are theorems rather than checks — the cut lands on the cumulative, so the slot differences telescope — and proptests hold them. - A rule with an end date carries the date. § 17 StromNEV lapses on 31.12.2028 and the Redispatch Pauschal-Abrechnung with it; both are constants the affected functions point at, so a 2029 settlement run can assert rather than remember.
- Where a source supplies a default, the crate has it; where it does not, the
crate takes an argument. § 42b Abs. 5 Satz 3 divides zu gleichen Teilen in
case of doubt, so
AllocationKey::EqualSharesexists. § 42c Abs. 3 Nr. 2 says nothing about a key's shape, so the other keys are offered as arithmetic and say on the type that they cite nothing. - No second copy of a fact. A register's unit comes from its OBIS code, a
meter exchange's date from its instant, an interval's direction from OBIS
value group C. Where a fact genuinely is stated twice, the disagreement is
reportable (
direction_conflict) rather than resolved in silence. - Serde tags are semver-covered, pinned literally by a test. Instants are
RFC 3339 and dates ISO 8601 in JSON,
time's compact tuple in binary formats; quantities are exact decimal strings everywhere, written per field rather than inherited from arust_decimalfeature — so enablingmetering/serdecannot change how aDecimalbehaves in a crate that never named this one. - Domain enums are exhaustive; only error enums are
#[non_exhaustive]. - Unknown is not good. Where a quantity cannot be determined the API says
so — an
Option, or an error — rather than a benign-looking default: a dynamisation factor for day 400, a peak across mixed resolutions, a market code the market does not have. Constructors are named for what they claim (MeterInterval::measured), so nothing acquires a quality by omission.
⚖️ Regulatory basis
Every provision is quoted from the published text and dated, and the library is explicit about the claims it cannot verify — the 2025 SLP dynamisation function is published as an image, G 685's final rounding diverges between Netzbetreiber, VDE-AR-N 4400 is paywalled, and no national rule fixes the Blindarbeit Freigrenze. Those are parameters, not constants.
The quotes are checked mechanically, not by recollection: just quotes matches
every German passage in the source, the site and this README against the PDFs
just specs fetches. Sixty-five of sixty-six verify character for character; the
one that cannot is a label inside a figure.
Nothing here is legal advice.
🧪 Testing
Beyond the unit tests:
tests/code_contract.rs— every coded enum, six properties each:ALLvsCODES,as_strisDisplay,FromStrinverts it, codes are distinct, theserdetag is the code, and an unknown code is an error — plus a scan of the crate source so a new enum cannot quietly skip the listtests/berlin_calendar.rs— DST interval counts against the tz database, and proptest over 1996–2065: days tile on both boundaries, a coarse interval count is the sum of the fine ones, stepping backndays and counting forward returnsntests/string_canonicalisation.rs— proptest: stability, totality, idempotence, injectivity of every string formtests/serde_representation.rs— every wire tag pinned literally, plus source scans holding that no timestamp or quantity field escapes the wire format and that the manifest enables norust_decimal/serde*featuretests/proptest_validation.rs— validation invariants under random inputtests/order_independence.rs— proptest: a shuffled series gives an identical result from every entry point that promises onetests/allocation_invariants.rs— proptest: the §42b/§42c allocation identities, and the §42b Abs. 5 pool ceiling, over generated communitiestests/quantity_invariants.rs— proptest: the conservation laws and bounds of every arithmetic module — differencing, resampling, gap filling, the register split, the Jahresprognose, unit and gas conversion, the gas SLP, Mehr-/Mindermengen, §14a and EN 50160tests/regulatory_showcase.rs— worked examples from the published sources- every code block in this README and on the documentation site, compiled
and run as a doctest — the pages are
include_str!'d into a#[cfg(doctest)]module, so every published block is covered rather than only the ones somebody copied into a mirror tests/arithmetic_conventions.rs— source scans: no quotient is used as a factor, and every rounding width is a named constanttests/doc_conventions.rs— no item doc over 60 lines, and no changelog prose in a reference doctests/scale.rs— a settlement year and a minute-sampled day through the pipeline: a smoke alarm for accidental quadratic behaviour, not a benchmarkscripts/verify_quotes.py— every quoted passage against the published PDF (just quotes; not a CI lane, because the corpus is not in the repository)
🤝 Contributing
Issues and pull requests welcome. A change that touches a regulated calculation should cite the provision it implements — and if a citation here is wrong, saying so is the most valuable issue you can file.
📄 License
Licensed under either of Apache-2.0 or MIT at your option.