metering 0.11.0

German energy metering domain library — 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 float money.
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
//! Named, annotated measurement series — grouping of intervals with provenance metadata.
//!
//! A [`MeasurementSeries`] is the semantic container that wraps a `Vec<MeterInterval>`
//! with the context required to explain every value:
//! - **who** measured it (MaLo, MeLo, meter serial, OBIS register)
//! - **how** it was produced (source, ingestion method, quality)
//! - **why** it exists (purpose, process reference)
//!
//! ## Relationship to domain objects
//!
//! ```text
//! MeasurementSeries
//!   ├── source_info: MeasurementSource   ← where data came from
//!   ├── measurement_point: Option<MeasurementPoint>  ← full location context
//!   ├── resolution: IntervalResolution   ← expected interval length
//!   ├── intervals: Vec<MeterInterval>    ← the actual data
//!   └── provenance: Vec<ProvenanceEntry> ← correction/substitution audit trail
//! ```
//!
//! ## §16 Explainability requirement
//!
//! Every stored interval should answer: *"Where did this value come from?"*
//! `MeasurementSeries` carries the answer at the series level; each
//! `MeterInterval.quality` answers it at the interval level.
//!
//! ## Legal basis
//!
//! - **§22 MessZV**: 3-year retention with full provenance for billing data.
//! - **§17 MessZV**: Substitute values must be traceable to their generation method.
//! - **BDEW MSCONS AHB**: Each MSCONS time series is a named series per OBIS code.

use time::OffsetDateTime;

#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};

use crate::interval::{MeterInterval, QualityFlag};
use crate::obis::ObisCode;
use crate::resolution::IntervalResolution;

// ── MeasurementSource ─────────────────────────────────────────────────────────

/// The origin of a measurement series — how the data entered the system.
///
/// Stored per series (not per interval) since all intervals in one MSCONS
/// message share the same ingestion source.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum MeasurementSource {
    /// Received via EDIFACT MSCONS (standard MaKo pipeline).
    ///
    /// The canonical source for DSO-metered customers. Follows the
    /// makod → marktd → edmd webhook pipeline.
    Mscons {
        /// MSCONS Prüfidentifikator (e.g. 13005).
        pid: u32,
        /// EDIFACT message reference.
        message_ref: Option<String>,
        /// BDEW Codenummer of the sending NB/MSB.
        sender_mp_id: String,
    },

    /// iMSys / SMGW direct push — bypasses EDIFACT pipeline.
    ///
    /// Used for §41a EnWG dynamic tariffs where MSCONS round-trip adds latency.
    SmgwDirectPush {
        /// SMGW device ID.
        device_id: String,
        /// Session ID for idempotency.
        session_id: String,
    },

    /// Manual entry by an operator.
    ///
    /// Used for corrections after meter replacement or dispute resolution.
    ManualEntry {
        /// Operator identifier (user ID or name).
        operator_id: String,
        /// Reason for manual entry.
        reason: String,
    },

    /// Automatic §17 MessZV substitute value generation.
    ///
    /// Triggered by gap detection (V01) in the validation engine.
    AutoSubstitute {
        /// Substitute method used.
        method: crate::substitute::SubstituteMethod,
        /// Reason for substitution.
        reason: crate::substitute::SubstitutionReason,
    },

    /// Retroactive §22 MessZV correction.
    ///
    /// Applied when an earlier value was found to be wrong.
    RetroactiveCorrection {
        /// ID of the original meter_read_corrections row.
        correction_id: uuid::Uuid,
        /// Who applied the correction.
        corrected_by: String,
    },

    /// Virtual meter computation (Sum/Residual/PvSelfConsumption/GgvAllocation).
    ///
    /// Derived from real measurements via `AggregationRule`.
    VirtualMeter {
        /// The aggregation rule type used.
        rule_type: String,
        /// Source MaLo IDs that contributed to this series.
        source_malo_ids: Vec<String>,
    },

    /// Redispatch 2.0 time-series import (PIDs 13020–13026).
    ///
    /// Ausfallarbeit, meteorological data, and other Redispatch quantities.
    RedispatchImport {
        /// MSCONS PID (13020–13026).
        pid: u32,
        /// Activation ID or process reference.
        activation_ref: Option<String>,
    },
}

impl MeasurementSource {
    /// Short human-readable label (German).
    #[must_use]
    pub fn label(&self) -> &'static str {
        match self {
            Self::Mscons { .. } => "MSCONS",
            Self::SmgwDirectPush { .. } => "SMGW-Direktpush",
            Self::ManualEntry { .. } => "Manuelle Eingabe",
            Self::AutoSubstitute { .. } => "§17 MessZV Auto-Ersatzwert",
            Self::RetroactiveCorrection { .. } => "§22 MessZV Korrektur",
            Self::VirtualMeter { .. } => "Virtueller Zähler",
            Self::RedispatchImport { .. } => "Redispatch 2.0",
        }
    }

    /// `true` when this source produces billable intervals per §17 MessZV.
    #[must_use]
    pub fn is_billable_source(&self) -> bool {
        matches!(
            self,
            Self::Mscons { .. }
                | Self::SmgwDirectPush { .. }
                | Self::AutoSubstitute { .. }
                | Self::RetroactiveCorrection { .. }
        )
    }
}

// ── ProvenanceEntry ───────────────────────────────────────────────────────────

/// An immutable audit record for a change applied to a series or interval.
///
/// Provenance entries are append-only — they record what happened and when,
/// but never overwrite earlier entries. This satisfies the §22 MessZV
/// 3-year audit trail requirement.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ProvenanceEntry {
    /// When this event occurred (UTC).
    pub occurred_at: OffsetDateTime,
    /// What kind of event this was.
    pub event_type: ProvenanceEventType,
    /// Who or what triggered this event.
    pub actor: String,
    /// Free-text note for regulatory audit trail.
    pub note: Option<String>,
}

/// Type of provenance event.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ProvenanceEventType {
    /// Initial ingest of the series.
    Ingested,
    /// Quality assessment run (V01-V10 validation).
    QualityAssessed,
    /// Gap detected and substitute value generated.
    SubstituteGenerated,
    /// Retroactive correction applied.
    Corrected,
    /// Archive to cold tier (Iceberg/S3).
    Archived,
    /// GDPR erasure request applied.
    Anonymised,
}

// ── MeasurementSeries ─────────────────────────────────────────────────────────

/// A named, annotated time series of meter intervals with full provenance.
///
/// This is the richest representation of meter data in the `metering` crate.
/// It combines all context required to answer §22 MessZV audit questions.
///
/// ## Usage
///
/// For most processing (aggregation, validation, resampling), use `Vec<MeterInterval>`
/// directly. Use `MeasurementSeries` at system boundaries where the full context
/// must be preserved: `edmd` storage, MCP tool responses, and ERP handoffs.
///
/// ## Relationship to `MeasurementPoint`
///
/// `MeasurementPoint` describes the **physical and regulatory binding** (MaLo,
/// MeLo, OBIS, MarktRolle). `MeasurementSeries` describes the **data series**
/// with its provenance. One `MeasurementPoint` can produce many
/// `MeasurementSeries` (e.g. one per MSCONS delivery).
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeasurementSeries {
    /// 11-digit MaLo-ID.
    pub malo_id: String,

    /// 33-character MeLo-ID (if available).
    pub melo_id: Option<String>,

    /// OBIS code identifying this measurement channel.
    pub obis_code: Option<ObisCode>,

    /// Expected interval resolution for this series.
    ///
    /// Derived from `obis_code.default_resolution()` when not explicitly set.
    pub resolution: Option<IntervalResolution>,

    /// How the data entered the system.
    pub source: MeasurementSource,

    /// The interval data, ordered by `from` ascending.
    pub intervals: Vec<MeterInterval>,

    /// Worst quality flag across all intervals.
    ///
    /// Pre-computed for fast filtering — recalculate after modifying `intervals`.
    pub worst_quality: QualityFlag,

    /// Audit trail for this series (ordered chronologically).
    pub provenance: Vec<ProvenanceEntry>,
}

impl MeasurementSeries {
    /// Construct a new series from intervals.
    ///
    /// Automatically computes `worst_quality` from the interval set.
    /// Adds an `Ingested` provenance entry with the current UTC time.
    #[must_use]
    pub fn new(
        malo_id: impl Into<String>,
        obis_code: Option<ObisCode>,
        intervals: Vec<MeterInterval>,
        source: MeasurementSource,
    ) -> Self {
        let worst_quality = worst_quality_of(&intervals);
        let resolution = obis_code.and_then(|o| o.default_resolution());
        Self {
            malo_id: malo_id.into(),
            melo_id: None,
            obis_code,
            resolution,
            source: source.clone(),
            intervals,
            worst_quality,
            provenance: vec![ProvenanceEntry {
                occurred_at: OffsetDateTime::now_utc(),
                event_type: ProvenanceEventType::Ingested,
                actor: source.label().to_owned(),
                note: None,
            }],
        }
    }

    /// Number of intervals in this series.
    #[must_use]
    pub fn interval_count(&self) -> usize {
        self.intervals.len()
    }

    /// `true` when the series has no intervals (e.g. empty MSCONS delivery).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.intervals.is_empty()
    }

    /// Total energy (kWh) across all billable intervals.
    #[must_use]
    pub fn total_kwh(&self) -> rust_decimal::Decimal {
        self.intervals
            .iter()
            .filter(|iv| iv.quality.is_billable())
            .map(|iv| iv.value_kwh)
            .sum()
    }

    /// Earliest interval start in this series.
    #[must_use]
    pub fn period_from(&self) -> Option<OffsetDateTime> {
        self.intervals.iter().map(|iv| iv.from).min()
    }

    /// Latest interval end in this series.
    #[must_use]
    pub fn period_to(&self) -> Option<OffsetDateTime> {
        self.intervals.iter().map(|iv| iv.to).max()
    }

    /// Recompute `worst_quality` from current `intervals`.
    ///
    /// Call this after modifying `intervals` directly.
    pub fn recompute_quality(&mut self) {
        self.worst_quality = worst_quality_of(&self.intervals);
    }

    /// Append a provenance entry.
    pub fn record_event(&mut self, event_type: ProvenanceEventType, actor: impl Into<String>) {
        self.provenance.push(ProvenanceEntry {
            occurred_at: OffsetDateTime::now_utc(),
            event_type,
            actor: actor.into(),
            note: None,
        });
    }
}

// ── helpers ───────────────────────────────────────────────────────────────────

fn quality_rank(q: QualityFlag) -> u8 {
    match q {
        QualityFlag::Faulty | QualityFlag::Unknown => 5,
        QualityFlag::Preliminary => 4,
        QualityFlag::Estimated => 3,
        QualityFlag::Corrected | QualityFlag::Substituted => 2,
        QualityFlag::Calculated => 1,
        QualityFlag::Measured => 0,
    }
}

fn worst_quality_of(intervals: &[MeterInterval]) -> QualityFlag {
    intervals
        .iter()
        .max_by_key(|iv| quality_rank(iv.quality))
        .map(|iv| iv.quality)
        .unwrap_or(QualityFlag::Unknown)
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::interval::QualityFlag;
    use crate::obis::ObisCode;
    use rust_decimal_macros::dec;
    use time::{Duration, macros::datetime};
    use uuid::Uuid;

    fn make_interval(from: time::OffsetDateTime, kwh: rust_decimal::Decimal) -> MeterInterval {
        MeterInterval {
            from,
            to: from + Duration::minutes(15),
            value_kwh: kwh,
            quality: QualityFlag::Measured,
            obis_code: None,
        }
    }

    #[test]
    fn new_series_computes_worst_quality() {
        let base = datetime!(2026-01-01 0:00 UTC);
        let mut intervals = vec![
            make_interval(base, dec!(1.0)),
            make_interval(base + Duration::minutes(15), dec!(1.0)),
        ];
        intervals[1].quality = QualityFlag::Estimated;

        let series = MeasurementSeries::new(
            "51238696780",
            Some(ObisCode::STROM_BEZUG_TOTAL),
            intervals,
            MeasurementSource::Mscons {
                pid: 13005,
                message_ref: None,
                sender_mp_id: "9900357000004".to_owned(),
            },
        );
        assert_eq!(series.worst_quality, QualityFlag::Estimated);
        assert_eq!(series.interval_count(), 2);
    }

    #[test]
    fn total_kwh_sums_billable_only() {
        let base = datetime!(2026-01-01 0:00 UTC);
        let mut intervals = vec![
            make_interval(base, dec!(2.0)),
            make_interval(base + Duration::minutes(15), dec!(1.0)),
        ];
        intervals[1].quality = QualityFlag::Faulty; // not billable

        let series = MeasurementSeries::new(
            "51238696780",
            None,
            intervals,
            MeasurementSource::ManualEntry {
                operator_id: "ops-001".to_owned(),
                reason: "test".to_owned(),
            },
        );
        assert_eq!(series.total_kwh(), dec!(2.0)); // only Measured interval
    }

    #[test]
    fn period_bounds_from_intervals() {
        let base = datetime!(2026-06-01 0:00 UTC);
        let intervals = vec![
            make_interval(base, dec!(1.0)),
            make_interval(base + Duration::minutes(15), dec!(1.0)),
        ];
        let series = MeasurementSeries::new(
            "51238696780",
            None,
            intervals,
            MeasurementSource::SmgwDirectPush {
                device_id: "SMGW-001".to_owned(),
                session_id: "sess-001".to_owned(),
            },
        );
        assert_eq!(series.period_from(), Some(base));
        assert_eq!(series.period_to(), Some(base + Duration::minutes(30)));
    }

    #[test]
    fn empty_series_reports_correctly() {
        let series = MeasurementSeries::new(
            "51238696780",
            None,
            vec![],
            MeasurementSource::AutoSubstitute {
                method: crate::substitute::SubstituteMethod::ZeroFill,
                reason: crate::substitute::SubstitutionReason::NoMeasurementAvailable,
            },
        );
        assert!(series.is_empty());
        assert_eq!(series.total_kwh(), rust_decimal::Decimal::ZERO);
        assert!(series.period_from().is_none());
    }

    #[test]
    fn source_labels_non_empty() {
        let sources = [
            MeasurementSource::Mscons {
                pid: 13005,
                message_ref: None,
                sender_mp_id: "x".into(),
            },
            MeasurementSource::SmgwDirectPush {
                device_id: "d".into(),
                session_id: "s".into(),
            },
            MeasurementSource::ManualEntry {
                operator_id: "o".into(),
                reason: "r".into(),
            },
            MeasurementSource::AutoSubstitute {
                method: crate::substitute::SubstituteMethod::LinearInterpolation,
                reason: crate::substitute::SubstitutionReason::MeterFault,
            },
            MeasurementSource::RetroactiveCorrection {
                correction_id: Uuid::new_v4(),
                corrected_by: "op".into(),
            },
            MeasurementSource::VirtualMeter {
                rule_type: "Sum".into(),
                source_malo_ids: vec![],
            },
            MeasurementSource::RedispatchImport {
                pid: 13022,
                activation_ref: None,
            },
        ];
        for s in &sources {
            assert!(!s.label().is_empty());
        }
    }

    #[test]
    fn obis_default_resolution_wires_into_series() {
        let series = MeasurementSeries::new(
            "51238696780",
            Some(ObisCode::STROM_BEZUG_TOTAL),
            vec![],
            MeasurementSource::Mscons {
                pid: 13005,
                message_ref: None,
                sender_mp_id: "x".into(),
            },
        );
        assert_eq!(
            series.resolution,
            Some(crate::resolution::IntervalResolution::QuarterHour)
        );
    }
}