1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
//! German energy metering domain library.
//!
//! A **standalone**, **pure** library for meter data calculations required by
//! BDEW MaKo, MsbG, GasGVV, and EnWG. Zero I/O, no async, no clock, exact
//! decimal quantities.
//!
//! It computes **energy and volume**, not money: there is no currency, price or
//! tariff-rate type here. What leaves this crate is kWh, m³ and kW, which a
//! billing layer then prices.
//!
//! # Modules
//!
//! | Module | Contents |
//! |---|---|
//! | [`interval`] | `MeterInterval`, `Sparte`, `QualityFlag`, `demand_kw()` |
//! | [`calendar`] | Europe/Berlin calendar days, months and years — DST-correct interval counts |
//! | [`error`] | `ParseError` — the single error type every `FromStr` here returns |
//! | [`conversion`] | Gas m³ → kWh_Hs (§25 Nr. 4 MessEV / DVGW G 685) |
//! | [`aggregation`] | Billing period: `arbeitsmenge_kwh`, `spitzenleistung_kw`, HT/NT |
//! | [`classification`] | SLP/RLM/iMSys detection, interval length |
//! | [`imbalance`] | Mehr-/Mindermengensaldo (§ 13 StromNZV, compute_imbalance) |
//! | [`quality`] | Hampel-filter quality scoring (M7), `score_intervals_raw` for f64 |
//! | [`validation`] | V01–V11 validation engine, order-independent |
//! | [`substitute`] | § 60 Abs. 2 MsbG Ersatzwertbildung (4 methods, slot matching) |
//! | [`forecast`] | § 60 Abs. 2 MsbG Jahresprognose with 95% confidence bounds |
//! | [`load_profile`] | SLP classes incl. BDEW 2025 (H25/G25/L25/P25/S25) + Dynamisierung |
//! | [`zaehlzeit`] | Zählzeitdefinition — time-variable register resolution (§14a EnWG) |
//! | [`rollout`] | §29 MsbG Pflichteinbaufälle + §45 MsbG Rollout-Fahrplan |
//! | [`smgw`] | SMGW sessions, certificates, CLS channels (BSI TR-03109) |
//! | [`virtual_meter`] | Sum/Residual/GGV virtual meters (§42b EnWG) |
//!
//! # Numeric types
//!
//! Every **metered quantity** is [`rust_decimal::Decimal`] — `value_kwh`,
//! `arbeitsmenge_kwh`, `spitzenleistung_kw`, the HT/NT split, the gas m³→kWh_Hs
//! conversion, GGV allocations and substitute values are exact decimal
//! arithmetic end to end, never routed through a float and back.
//!
//! `f64` is used, deliberately, for **statistics and diagnostics**:
//! `coverage_pct`, the Hampel filter's threshold and median absolute deviation,
//! [`ValidationConfig::spike_factor`] and the forecast confidence bounds. Those
//! are comparisons and indicators, not amounts.
//!
//! The two meet in exactly one place, documented at the call site: the V04 spike
//! rule converts a value to `f64` to compare it against the `f64` spike factor.
//! That comparison decides whether to *flag* an interval; it never alters one.
//!
//! # Determinism
//!
//! **No function in this crate reads the system clock, the filesystem, the
//! network or any other ambient state.** Every input is an argument, so equal
//! inputs always produce equal outputs, and any result can be replayed or
//! cached. Where a timestamp is needed — a provenance entry, an SMGW
//! communication-fault assessment — 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: "test".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. CI enforces this
//! with a grep over non-test code.
//!
//! # Time is Europe/Berlin
//!
//! German metering periods are **local calendar periods**. A day, a month and a
//! §13 StromNZV settlement period all begin at 00:00 Europe/Berlin — 23:00 UTC
//! the previous day in winter, 22:00 UTC in summer — and a day is 23 or 25 hours
//! long at the DST transitions. [`calendar`] owns those rules; [`mod@resample`]
//! buckets through it, and [`tariff_window`] and [`zaehlzeit`] resolve local
//! windows through the same tz database. Interval timestamps themselves are
//! always UTC.
//!
//! Because a calendar period has no fixed second count,
//! [`IntervalResolution::fixed_seconds`] returns `None` for `Day`, `Month` and
//! `Year` rather than an approximation — see [`calendar::intervals_in_day`].
//!
//! # One value, one string
//!
//! Every type here that has a string form has **exactly one** of them. 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.
//!
//! - Unit enums ([`Sparte`], [`QualityFlag`], [`MeasurementUnit`]): `as_str`,
//! [`Display`], [`FromStr`] and the `serde` tag are the same code.
//! - [`ObisCode`]: `1-0:1.8.0`. The `*F` group is omitted when F is 255 ("not
//! applicable", IEC 62056-6-1) — the spelling MSCONS carries and people type.
//! - [`IntervalResolution`]: the ISO 8601 duration, `PT15M` / `P1D`.
//!
//! Parsing is deliberately lenient where writing is not: [`ObisCode`] also
//! accepts `1-0:1.8.0*255`, leading zeros and surrounding whitespace, and
//! [`IntervalResolution`] accepts `PT900S` and lower case. Every accepted
//! spelling maps onto the one canonical output, so
//!
//! ```rust
//! # use metering::ObisCode;
//! // ...whichever spelling arrived, one key comes out.
//! assert_eq!(ObisCode::normalize("1-0:1.8.0*255")?, "1-0:1.8.0");
//! assert_eq!(ObisCode::normalize(" 1-0:01.8.0 ")?, "1-0:1.8.0");
//! # Ok::<(), metering::ParseError>(())
//! ```
//!
//! `s.parse()?.to_string() == s` holds for every canonical `s`, and
//! `tests/string_canonicalisation.rs` holds stability, totality, idempotence and
//! injectivity under proptest.
//!
//! Where a code legitimately carries a storage group — a historical billing
//! period, `1-0:1.8.0*1` — it is never elided, because there the suffix is
//! information rather than noise.
//!
//! [`Display`]: std::fmt::Display
//! [`FromStr`]: std::str::FromStr
//!
//! # Serde representation stability
//!
//! With the `serde` feature enabled, the emitted representation — enum tags and
//! struct field names — is **part of the public API and covered by semver**.
//! Consumers persisting these values are relying on a wire format, so a rename
//! is a breaking change and will be released as one. `tests/serde_representation.rs`
//! pins every tag literally so the commitment is mechanical rather than a promise.
//!
//! You do not need to define your own storage codes to insulate yourself from
//! renames here; if you would rather anyway, that is a deliberate choice and not
//! a hedge against an unstated policy.
//!
//! Each unit enum's tag is identical to its `as_str` code, and the two types
//! with a canonical string ([`ObisCode`], [`IntervalResolution`]) serialise *as*
//! that string, so `serde`, [`std::fmt::Display`] and [`std::str::FromStr`]
//! never disagree. `ObisCode` in particular is stable for a further reason: it
//! is an IEC 62056 identifier and `IntervalResolution` an ISO 8601 duration —
//! both external standards, which no refactor in this crate can rename.
//!
//! # Enum exhaustiveness
//!
//! **Domain enums here are exhaustive; only error enums are
//! `#[non_exhaustive]`.** This is a deliberate choice, not an oversight.
//!
//! `#[non_exhaustive]` buys the *library* the freedom to add a variant without
//! a major version, and charges the *consumer* a wildcard arm for it. That
//! wildcard is where the cost lands: when a new [`Messtyp`],
//! [`SubstituteMethod`] or [`QualityFlag`] appears, a consumer mapping this
//! crate's vocabulary onto their own storage codes wants their build to break,
//! so a human decides what the new variant means. With a wildcard they instead
//! get a silent fallback — 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 the cheaper
//! failure by a wide margin.
//!
//! Error enums are the opposite case, and [`VirtualMeterError`] is marked
//! accordingly: a consumer that wildcards an unfamiliar error still does the
//! right thing — it reports a failure — so there is nothing to protect.
//!
//! The consequence is that **adding a variant to a domain enum is a breaking
//! change here**, and will be released as one. Adding a variant to an error enum
//! is not. Exhaustive `match` over `Sparte::ALL`, `QualityFlag::ALL`,
//! `MeasurementUnit::ALL` or `LoadProfile::ALL` is a supported pattern, and each
//! `ALL` is covered by a test that fails if it falls out of step with `CODES`.
//!
//! # Quick start — billing period
//!
//! ```rust
//! use metering::{MeterInterval, QualityFlag, aggregate, AggregationConfig};
//! use rust_decimal::Decimal;
//! use time::macros::datetime;
//!
//! let iv = MeterInterval {
//! from: datetime!(2026-06-01 0:00 UTC),
//! to: datetime!(2026-06-01 0:15 UTC),
//! value_kwh: Decimal::from_str_exact("2.345").unwrap(),
//! quality: QualityFlag::Measured,
//! obis_code: None,
//! };
//! let period = aggregate(&[iv], &AggregationConfig::rlm_strom());
//! assert!(period.arbeitsmenge_kwh > Decimal::ZERO);
//! ```
//!
//! # Quick start — Gas m³ → kWh_Hs
//!
//! ```rust
//! use metering::gas_m3_to_kwh_hs;
//! use rust_decimal::Decimal;
//!
//! let kwh = gas_m3_to_kwh_hs(
//! Decimal::from(100u32),
//! Decimal::from_str_exact("10.55").unwrap(),
//! Decimal::from_str_exact("0.9764").unwrap(),
//! );
//! assert!(kwh > Decimal::from(1000u32));
//! ```
//!
//! # Quick start — quality scoring (f64 API, e.g. from DB)
//!
//! ```rust
//! use metering::{score_intervals_raw, QualityGrade};
//!
//! let values = vec![2.3_f64, 2.4, 2.3, 2.5, 2.2, 2.4, 2.3];
//! let grade = score_intervals_raw(&values, 3, 3.0);
//! assert_eq!(grade, QualityGrade::A);
//! ```
// ── Re-exports ────────────────────────────────────────────────────────────────
pub use ;
pub use AggregationRule;
pub use ;
pub use ;
pub use ;
pub use ;
pub use ParseError;
pub use ;
pub use ;
pub use ;
pub use ;
pub use LoadProfile;
pub use ;
pub use ;
pub use ;
pub use ObisCode;
pub use PowerQualityInterval;
pub use ;
pub use ;
pub use ;
pub use IntervalResolution;
pub use ;
pub use ;
pub use ;
pub use ;
pub use ;