metering 0.20.0

German energy metering domain library — DST-correct Europe/Berlin calendar arithmetic on both the Liefertag and the 06:00 Gastag, gas m3→kWh_Hs conversion, SigLinDe gas SLP, billing-period aggregation, Ersatzwertbildung, a robust validation engine, para 14a netzorientierte Steuerung and Modul 3 registers, para 42b/42c allocation, check-digit-validated MaLo-IDs and SLP/RLM/iMSys classification. Zero I/O, no async, no clock, exact decimal quantities.
Documentation
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
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
//! German energy metering domain library.
//!
//! A **standalone**, **pure** library for the quantity calculations German
//! energy metering requires — MsbG, EnWG, StromNEV, MessEV and the BNetzA
//! Festlegungen. 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` — the Lastgang; `Sparte`, `QualityFlag` |
//! | [`reading`] | `MeterReading` — the Zählerstand, and the ZSG → Lastgang conversion |
//! | [`obis`] | Typed `ObisCode`, one canonical string per channel |
//! | [`ids`] | Typed `MaloId` (check-digit validated), `MeloId` and `BdewCode` |
//! | [`calendar`] | Europe/Berlin days, months, years **and the 06:00 Gastag** — DST-correct; `DayBoundary` |
//! | [`holiday`] | Bundesland statutory holidays; SLP day typing |
//! | [`resolution`] | `IntervalResolution` — fixed vs calendar lengths |
//! | [`conversion`] | Gas m³ → kWh_Hs, unit normalisation, HeizkostenV warm water |
//! | [`aggregation`] | Billing period: Arbeitsmenge, Spitzenleistung, coverage |
//! | [`zaehlzeit`] | Tariff registers — HT/NT and § 14a Modul 3, with a conformance check |
//! | [`para14a`] | § 14a netzorientierte Steuerung — `P_min,14a` and the netzwirksamer Leistungsbezug |
//! | [`mod@resample`] | Down-sampling to Berlin calendar buckets |
//! | [`validation`] | The rule engine — V01–V09, V11, V12 — order-independent, and it reports which rules ran |
//! | [`quality`] | Hampel filter, and an A/B/C/F grade over the findings |
//! | [`substitute`] | Ersatzwertbildung — 4 methods, with an audit trail |
//! | [`forecast`] | Jahresprognose with a 95 % prediction interval |
//! | [`load_profile`] | SLP classes incl. BDEW 2025 (H25/G25/L25/P25/S25) |
//! | [`gas_slp`] | Gas SLP arithmetic — SigLinDe, Allokationstemperatur, Kundenwert |
//! | [`classification`] | SLP / RLM / iMSys detection from the observed series |
//! | [`virtual_meter`] | Sum / Residual / GGV virtual meters (§ 42b EnWG), per tenant and per community |
//! | [`aggregation_rule`] | The rules `virtual_meter` evaluates |
//! | [`imbalance`] | Jahresmehr-/-mindermengen (GPKE Kap. 8.4) |
//! | [`losses`] | Netzverlust balance (§ 22 Abs. 1 EnWG) |
//! | [`power_quality`] | EN 50160 — statistical, over a week of 10-minute means; VDE-AR-N 4100 Unsymmetrie |
//! | [`measurement_point`] | What is metered, and on whose account |
//! | [`measurement_series`] | A named series with its provenance |
//! | [`lifecycle`] | Meter installation, exchange and retirement events |
//! | [`rollout`] | § 29 MsbG Pflichteinbaufälle + § 45 MsbG Rollout-Fahrplan |
//! | [`sharing`] | § 42c EnWG Energy-Sharing eligibility |
//! | [`error`] | `ParseError` — the one error every `FromStr` here returns |
//!
//! # Numeric types
//!
//! Every **metered quantity** is [`rust_decimal::Decimal`] — `value`,
//! `arbeitsmenge`, `spitzenleistung_kw`, the per-register 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 median absolute deviation and threshold,
//! and the forecast prediction interval. Those are comparisons and indicators,
//! not amounts.
//!
//! The two meet in exactly one place: the V04 outlier rule converts values to
//! `f64` to run the Hampel filter over them. That comparison decides whether to
//! *flag* an interval; it never alters one. Nothing a float touches is ever
//! written back into a quantity.
//!
//! ## What "exact" means here
//!
//! **No float, and one rounding at most.** The first half is absolute: a
//! metered quantity is a `Decimal` from the wire to the invoice and never
//! passes through `f64`. The second half is the part worth stating, because
//! "exact decimal" is often read as "never rounds", and that is not true of any
//! decimal type. `Decimal` carries 28–29 significant digits, and a division
//! whose quotient does not terminate is rounded to that width — `2 ÷ 3` is
//! `0.666…7` here as anywhere.
//!
//! Addition, subtraction and multiplication of quantities at realistic scales
//! do not round at all, which is why the **conservation laws hold exactly**: a
//! register split reconstructs its Arbeitsmenge, a filled series covers its
//! grid, an allocation splits a consumption into a credited and a drawn part,
//! a resampling preserves the energy it buckets.
//! `tests/quantity_invariants.rs` asserts each of them over generated input.
//!
//! Division is where a choice has to be made, and the crate makes it one of two
//! ways:
//!
//! - **Cut to a documented number of places** when the quotient is a value a
//!   consumer stores, prints or settles on, or when an identity depends on it:
//!   [`ALLOCATION_DP`] (6), [`FORECAST_DP`] (3),
//!   [`SigLinDe::H_VALUE_DP`](gas_slp::SigLinDe::H_VALUE_DP) (6),
//!   [`KUNDENWERT_DP`](gas_slp::KUNDENWERT_DP) (4). A share carrying
//!   twenty-seven decimal places is not a quantity, and it breaks the
//!   subtraction that follows it.
//! - **Leave it at full width** when it is an intermediate nothing downstream
//!   can distinguish: [`allocation_temperature`] feeds only `h_value`, which
//!   crosses into `f64` at once.
//!
//! Where a rounding rule is the *market's* rather than this crate's it is a
//! parameter instead, with a documented default —
//! [`G685Rounding`] is the case where published
//! Netzbetreiber practice demonstrably disagrees with itself.
//!
//! A cut quantity is therefore homogeneous only to its last reported place:
//! doubling every reading doubles a projection to within `2 × 10⁻³` kWh, not
//! exactly, because `round(2x)` and `2·round(x)` differ at a rounding boundary.
//!
//! # 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, a validity check,
//! a "was this reading in the future" test — it is a parameter:
//!
//! ```rust
//! # use metering::{MeasurementSeries, MeasurementSource};
//! # use time::macros::datetime;
//! let series = MeasurementSeries::new(
//!     "51238696781".parse()?, // a MaloId — the check digit is verified
//!     None,
//!     vec![],
//!     MeasurementSource::ManualEntry { operator_id: "ops-1".into(), reason: "test".into() },
//!     datetime!(2026-01-02 09:30 UTC), // ← supplied, never sampled
//! );
//! # Ok::<(), metering::ParseError>(())
//! ```
//!
//! 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 Liefertag, a
//! Liefermonat and a settlement year 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 [`zaehlzeit`] and
//! [`holiday`] resolve local windows and dates through the same tz database.
//!
//! Interval timestamps themselves are always **UTC**, which is the market's own
//! split. EDI@Energy *Allgemeine Festlegungen zu den EDIFACT- und
//! XML-Nachrichten* v6.1c (01.10.2025, binding from 01.04.2026), Kap. 3:
//! *"Die Angabe von Zeiten in einer EDIFACT Nachricht erfolgt in koordinierter
//! Weltzeit (Coordinated Universal Time, UTC). … Alle in den Prozessen
//! genannten Zeitpunkte (inkl. der sich unter Berücksichtigung von Fristen
//! ergebenden Zeitpunkte) nutzen die gesetzliche deutsche Zeit."*
//!
//! 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`].
//!
//! **A day is cut in one of two places.** Electricity settles on the Liefertag,
//! which begins at 00:00 local; gas settles on the **Gastag**, which begins at
//! 06:00. [`DayBoundary`] carries that choice through
//! [`ResampleConfig::on`], [`FillGapsConfig::on`] and [`ValidationConfig::on`],
//! so a daily, monthly or yearly gas figure is a whole number of Gastage rather
//! than a calendar total shifted six hours — and a daily gas series is judged
//! against the Gastag's own 23, 24 or 25 hours rather than against a flat
//! 86 400 s. The length is unaffected — both are "one day" — which is why the
//! boundary is a separate parameter and not a resolution.
//!
//! Where a local wall-clock time has to become an instant, the two awkward
//! cases resolve once and consistently: the repeated autumn hour takes the
//! **earlier** pass, so periods tile; the skipped spring hour is pushed
//! **forward by the gap**, so 02:30 on the transition Sunday becomes 03:30 —
//! the convention `java.time`, `chrono` and Python's `zoneinfo` share.
//!
//! # 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.
//!
//! **Every coded enum carries the whole contract**: `ALL`, `CODES`, `as_str`,
//! [`Display`], [`FromStr`], and a `serde` tag that *is* the `as_str` code.
//! That last equality is what lets a consumer generate a database `CHECK`
//! constraint from `CODES` and know the two cannot drift;
//! `tests/code_contract.rs` asserts all six properties for every one of them.
//!
//! - Unit enums ([`Sparte`], [`QualityFlag`], [`MeasurementUnit`]): `as_str`,
//!   [`Display`], [`FromStr`] and the `serde` tag are the same code.
//! - A **code** and a **description** are different things, and the types with
//!   both keep them apart: [`Holiday::as_str`] is `BUSS_UND_BETTAG` and
//!   [`Holiday::name`] is *"Buß- und Bettag"*; [`RegisterUnit::as_str`] is
//!   `KILO_WATT_HOUR` and [`RegisterUnit::symbol`] is `kWh`.
//! - Parsing accepts a few **input aliases** — `WÄRME` for [`Sparte::Waerme`],
//!   `DE-BY` for [`Bundesland::By`], `ÜNB` for [`MarktRolle::Uenb`] — which are
//!   never written back. An alias is not a code and is deliberately absent from
//!   `CODES`.
//!
//! [`Holiday::as_str`]: holiday::Holiday::as_str
//! [`Holiday::name`]: holiday::Holiday::name
//! [`RegisterUnit::as_str`]: obis::RegisterUnit::as_str
//! [`RegisterUnit::symbol`]: obis::RegisterUnit::symbol
//! - [`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`. Its
//!   `Custom` payload is opaque ([`CustomSeconds`]), so a length with a name
//!   cannot also be spelled as a custom one.
//! - [`MaloId`]: the eleven digits, with the **check digit verified** at the
//!   parse; [`MeloId`]: the 33-character Zählpunktbezeichnung, uppercased.
//!
//! 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.
//!
//! Each unit enum's tag is identical to its `as_str` code, and the types with a
//! canonical string ([`ObisCode`], [`IntervalResolution`], [`MaloId`],
//! [`MeloId`], [`BdewCode`]) 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.
//!
//! ## Instants are RFC 3339, dates are ISO 8601 — in a readable format
//!
//! An instant travels as `"2026-06-01T12:00:00Z"` and a calendar date as
//! `"2026-06-01"`, which is what a `TIMESTAMPTZ` cast, a JSON Schema
//! `format: date-time` and every log viewer already understand.
//!
//! In a **binary** format they keep `time`'s own compact tuple instead. That
//! split is deliberate: [`MeterInterval`] carries two instants and is the
//! hottest type here, and a twenty-byte string per boundary is a poor trade in
//! a format chosen for its packing. `serde` is asked which kind of format it is
//! and the answer decides — see the private `wire` module.
//!
//! ## The hot types survive a non-self-describing format
//!
//! `MeterInterval`, `ObisCode`, [`MeterReading`] and the identifiers round-trip
//! through bincode and postcard. The types with an internally tagged `serde`
//! shape — [`AggregationRule`], [`AllocationKey`] — deliberately do not: a
//! discriminator at a fixed, queryable path is worth more for configuration
//! stored once per delivery point than binary compactness is.
//!
//! Both halves are pinned by a test. The `rust_decimal/serde-str` feature is
//! what makes the first half hold: the default deserialiser asks
//! `deserialize_any`, which is the one question a format without a
//! self-describing wire cannot answer.
//!
//! # A clean report is not the same as a clean series
//!
//! Four of the eleven validation rules are **opt-in**: they need a number this
//! library refuses to invent — a grid spacing, an outlier threshold, a
//! reference instant, a plant capacity — and leaving the corresponding
//! [`ValidationConfig`] field `None` turns the rule off. Two more are
//! **opt-out**: `negative_energy_is_error = false` retires V03, and a
//! `zero_run_threshold` of `0` retires V05. A [`ValidationResult`] with no
//! issues therefore means *"the rules that ran found nothing"*, which is weaker
//! than "nothing is wrong".
//!
//! So the crate says which ran: [`ValidationConfig::disabled_rules`] before a
//! run, [`ValidationResult::evaluated`] after one, and
//! [`ValidationRuleId::enabling_field`] names the setting that would arm a rule.
//!
//! ```rust
//! use metering::{QualityConfig, Sparte, ValidationRuleId};
//!
//! // A "now" and a nameplate capacity are not properties of a commodity.
//! let cfg = QualityConfig::for_sparte(Sparte::Strom);
//! assert_eq!(cfg.validation.disabled_rules().to_string(), "V08, V12");
//! assert_eq!(
//!     ValidationRuleId::ImplausiblePower.enabling_field(),
//!     Some("max_plant_power_kw"),
//! );
//! ```
//!
//! # Order in, order out
//!
//! Where a function takes a slice of intervals, the order of that slice does
//! not change the answer. A MSCONS delivery merged from two files, a database
//! query without an `ORDER BY` and a `HashMap` iteration all arrive shuffled,
//! so [`aggregate`], [`mod@resample`], [`validate_intervals`], [`fill_gaps`],
//! [`Zaehlzeitdefinition::split_energy`] and [`to_lastgang`] each sort, index
//! or fold over an operation that commutes.
//!
//! A **tie** is where the promise is easiest to break, so both places one can
//! occur are settled explicitly: [`QualityFlag::severity_rank`] gives every
//! flag a distinct rank, and [`aggregate`] breaks a tied peak by the earliest
//! instant. `tests/order_independence.rs` asserts the whole property under
//! proptest.
//!
//! [`spitzenleistung_at`]: BillingPeriod::spitzenleistung_at
//! [`Zaehlzeitdefinition::split_energy`]: zaehlzeit::Zaehlzeitdefinition::split_energy
//!
//! # Enum exhaustiveness
//!
//! **Domain enums here are exhaustive; only error enums are
//! `#[non_exhaustive]`.**
//!
//! `#[non_exhaustive]` charges the consumer a wildcard arm, and that is where
//! the cost lands: a consumer mapping a new [`Messtyp`] or [`SubstituteMethod`]
//! onto their own storage codes wants their build to break so a human decides
//! what it means, not a silent fallback filing a reading under the wrong one.
//! For output that ends up on an invoice, a compile error at upgrade time is
//! the cheaper failure. An unfamiliar *error* needs no such protection — a
//! wildcard still reports a failure — so exactly three types carry the
//! attribute, and all three are failure vocabularies: [`VirtualMeterError`],
//! [`ConversionError`] and [`AnomalyKind`], the reason an [`Anomaly`] refused a
//! difference.
//!
//! So **adding a variant to a domain enum is a breaking change here** and is
//! released as one, and exhaustive `match` over any `ALL` is a supported
//! pattern.
//!
//! # 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: Decimal::from_str_exact("2.345").unwrap(),
//!     quality: QualityFlag::Measured,
//!     obis_code: None,
//! };
//! let period = aggregate(&[iv], &AggregationConfig::rlm());
//! assert!(period.arbeitsmenge > 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 — validate, then grade
//!
//! ```rust
//! use metering::{MeterInterval, QualityConfig, QualityGrade, QualityFlag, score_intervals};
//! use rust_decimal::dec;
//! use time::{Duration, macros::datetime};
//!
//! let day: Vec<MeterInterval> = (0..96).map(|i| MeterInterval {
//!     from: datetime!(2026-06-01 0:00 UTC) + Duration::minutes(i * 15),
//!     to:   datetime!(2026-06-01 0:00 UTC) + Duration::minutes(i * 15 + 15),
//!     value: dec!(2.0),
//!     quality: QualityFlag::Measured,
//!     obis_code: None,
//! }).collect();
//!
//! let report = score_intervals(&day, &QualityConfig::default());
//! assert_eq!(report.grade, QualityGrade::A);
//! assert!(report.issues.is_empty());
//! ```
//!
//! # Scope — what belongs here and what does not
//!
//! This crate computes **quantities**. It deliberately excludes three
//! neighbouring concerns, each of which has its own home:
//!
//! | Not here | Why | Where |
//! |---|---|---|
//! | Money — prices, tariffs, invoices | what leaves here is kWh, m³ and kW | a billing layer |
//! | EDIFACT / XML market messages | parsing a MSCONS is not arithmetic | [`mako`](https://github.com/hupe1980/mako) |
//! | Fristen — counting Werktage to a GPKE deadline | a process-engine concern | a process engine |
//!
//! The third is the least obvious. [`holiday`] does carry a German statutory
//! holiday calendar, because SLP day typing and HT/NT classification cannot be
//! done without one — but it counts no business days and knows nothing of the
//! EDI@Energy rule that a holiday in one Bundesland counts nationwide.
#![deny(unsafe_code)]
#![warn(missing_docs)]

#[macro_use]
mod codes;
mod wire;

pub mod aggregation;
pub mod aggregation_rule;
pub mod calendar;
pub mod classification;
pub mod conversion;
pub mod error;
pub mod forecast;
pub mod gas_slp;
pub mod holiday;
pub mod ids;
pub mod imbalance;
pub mod interval;
pub mod lifecycle;
pub mod load_profile;
pub mod losses;
pub mod measurement_point;
pub mod measurement_series;
pub mod obis;
pub mod para14a;
pub mod power_quality;
pub mod quality;
pub mod reading;
pub mod resample;
pub mod resolution;
pub mod rollout;
pub mod sharing;
pub mod substitute;
pub mod validation;
pub mod virtual_meter;
pub mod zaehlzeit;

// ── Re-exports ────────────────────────────────────────────────────────────────

pub use aggregation::{AggregationConfig, BillingPeriod, aggregate};
pub use aggregation_rule::{AggregationRule, VirtualMeterKind};
pub use calendar::{
    DayBoundary, DayKind, day_length, day_start_utc, gas_day_start_utc, intervals_in_day,
    local_day, local_gas_day,
};
pub use classification::{Messtyp, SeriesOrigin, classify_messtyp, detect_interval_length};
pub use conversion::{
    ConversionError, G685FinalRounding, G685Rounding, GasConversionParams, WarmWaterAdjustments,
    gas_m3_to_kwh_hs, gas_m3_to_kwh_hs_rounded, normalize_to_kwh, warm_water_heat_kwh,
    warm_water_heat_kwh_unmetered,
};
pub use error::ParseError;
pub use forecast::{AnnualForecast, FORECAST_DP, project_annual_consumption};
pub use gas_slp::{
    SigLinDe, WeekdayFactors, allocation_temperature, gas_daily_quantity, kundenwert,
};
pub use holiday::{Bundesland, Holiday, slp_day_type};
pub use ids::{BdewCode, CodeVergabestelle, MaloId, MaloIssuer, MeloId};
pub use imbalance::{ImbalanceSaldo, compute_imbalance};
pub use interval::{MeasurementUnit, MeterInterval, QualityFlag, Sparte, UnitScale};
pub use lifecycle::{
    MeterExchangeEvent, MeterLifecycleEvent, MeterLifecycleEventType, MeterStatus,
};
pub use load_profile::{DynamicSlpProfile, Dynamization, LoadProfile, SlpDayType, SlpValueTable};
pub use losses::{NetworkLosses, network_losses};
pub use measurement_point::{EnergyFlow, MarktRolle, MeasurementPoint};
pub use measurement_series::{
    MeasurementSeries, MeasurementSource, ProvenanceEntry, ProvenanceEventType,
};
pub use obis::{ObisCode, RegisterUnit};
pub use para14a::{
    GLEICHZEITIGKEITSFAKTOREN, MINDESTLEISTUNG_KW, Para14aConfig, STEUVE_SCHWELLE_KW, SteuVe,
    SteuVeFallgruppe, Verursachungsregel, gleichzeitigkeitsfaktor,
    mindestleistung_direktansteuerung, mindestleistung_ems, netzwirksamer_leistungsbezug,
};
pub use power_quality::{
    En50160Limits, En50160Report, LimitOutcome, Phase, PhaseApparentPower, PowerQualityInterval,
    UNSYMMETRIE_LIMIT_KVA, assess_en50160, exceedance_pct, voltage_percentile,
};
pub use quality::{
    K_MAD, QualityConfig, QualityGrade, QualityReport, hampel_filter, hampel_filter_with_floor,
    score_intervals,
};
pub use reading::{
    Anomaly, AnomalyKind, Lastgang, LastgangConfig, MeterReading, ResultChannel, Rollover,
    consumption_between, detect_reading_cadence, to_lastgang,
};
pub use resample::{ResampleConfig, ResampledBucket, resample};
pub use resolution::{CustomSeconds, IntervalResolution};
pub use rollout::{
    QuotaScope, ROLLOUT_MILESTONES, RolloutMilestone, RolloutObligation,
    classify_rollout_obligation, next_milestone,
};
pub use sharing::{
    Bilanzierungsmethode, Capability, Delivery, DeliveryEvidenceInput, EligibilityBasis, Finding,
    MeteringCapabilityInput, SharingReadiness, Zaehlertyp, assess_capability, assess_delivery,
    combine_readiness,
};
pub use substitute::{
    FillGapsConfig, FilledSeries, SubstituteEntry, SubstituteMethod, SubstitutionReason, fill_gaps,
};
pub use validation::{
    RuleSet, ValidationConfig, ValidationIssue, ValidationResult, ValidationRuleId,
    ValidationSeverity, validate_intervals,
};
pub use virtual_meter::{
    ALLOCATION_DP, AllocationKey, CommunityInterval, GgvInterval, ParticipantAllocation,
    VirtualMeterError, compute_community_allocation, compute_ggv_allocation, compute_virtual_meter,
};
pub use zaehlzeit::{
    DayGroup, Modul3Conformance, Modul3Context, Modul3Finding, Quarter, ZaehlzeitFenster,
    Zaehlzeitdefinition, assess_modul_3,
};