metering 0.16.0

German energy metering domain library — Europe/Berlin calendar arithmetic (DST-correct interval counts), interval types, Gas conversion (m3→kWh_Hs), billing period aggregation (Spitzenleistung, HT/NT), SLP/RLM/iMSys classification, Mehr-/Mindermengensaldo, and Hampel-filter quality scoring. 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
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
# ⚡ metering

[![Crates.io](https://img.shields.io/crates/v/metering.svg)](https://crates.io/crates/metering)
[![Docs.rs](https://docs.rs/metering/badge.svg)](https://docs.rs/metering)
[![CI](https://github.com/hupe1980/metering/actions/workflows/ci.yml/badge.svg)](https://github.com/hupe1980/metering/actions/workflows/ci.yml)
[![MSRV](https://img.shields.io/badge/MSRV-1.94-blue.svg)](https://blog.rust-lang.org/)
[![License](https://img.shields.io/crates/l/metering.svg)](#-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:

| 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** |

```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

| Module | Key types | Purpose |
|---|---|---|
| `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

| Constraint | Detail |
|---|---|
| 🧊 **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**:

| Kind | Type | Examples |
|---|---|---|
| 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:

| `Sparte` | `measured_unit()` | `billing_unit()` | `requires_conversion()` |
|---|---|---|---|
| `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 })
```

| Accepted | Canonical | Factor |
|---|---|---|
| `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:

| Rec 20 | Means | Trap |
|---|---|---|
| `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)

| Variant | Billable | Description |
|---|---|---|
| `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:

| Constant | Covers (§9 Abs. 2 Satz 3/5) |
|---|---|
| 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`

| Field | Effect | Trigger |
|---|---|---|
| `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());
```

| Rule | ID | Detects |
|---|---|---|
| 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.

| `SubstituteMethod` | When to use |
|---|---|
| `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

| Preset | Messtyp | Detail |
|---|---|---|
| `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
```

| `Messtyp` | Criteria | Basis |
|---|---|---|
| `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:

| A | Medium | Predicate | Constant |
|---|---|---|---|
| 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

| `Sparte` | `max_zero_run_allowed` | `min_sigma` |
|---|---|---|
| `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`

| Grade | Meaning | Suggested downstream effect |
|---|---|---|
| `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.

| Type | Canonical string |
|---|---|
| `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

| Flag | Default | Effect |
|---|---|---|
| `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:

| Suite | Covers |
|---|---|
| [`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.