use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::aggregation_rule::VirtualMeterKind;
use crate::ids::{MaloId, MeloId};
use crate::interval::{MeterInterval, QualityFlag};
use crate::obis::ObisCode;
use crate::resolution::IntervalResolution;
#[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 {
Mscons {
pid: u32,
message_ref: Option<String>,
sender_mp_id: String,
},
SmgwDirectPush {
device_id: String,
session_id: String,
},
ManualEntry {
operator_id: String,
reason: String,
},
AutoSubstitute {
method: crate::substitute::SubstituteMethod,
reason: crate::substitute::SubstitutionReason,
},
RetroactiveCorrection {
correction_id: uuid::Uuid,
corrected_by: String,
},
VirtualMeter {
rule: VirtualMeterKind,
source_ids: Vec<String>,
},
RedispatchImport {
pid: u32,
activation_ref: Option<String>,
},
}
impl MeasurementSource {
#[must_use]
pub fn label(&self) -> &'static str {
match self {
Self::Mscons { .. } => "MSCONS",
Self::SmgwDirectPush { .. } => "SMGW-Direktpush",
Self::ManualEntry { .. } => "Manuelle Eingabe",
Self::AutoSubstitute { .. } => "Automatischer Ersatzwert",
Self::RetroactiveCorrection { .. } => "Nachträgliche Korrektur",
Self::VirtualMeter { .. } => "Virtueller Zähler",
Self::RedispatchImport { .. } => "Redispatch 2.0",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ProvenanceEntry {
pub occurred_at: OffsetDateTime,
pub event_type: ProvenanceEventType,
pub actor: String,
pub note: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum ProvenanceEventType {
Ingested,
QualityAssessed,
SubstituteGenerated,
Corrected,
Archived,
Anonymised,
}
impl ProvenanceEventType {
pub const ALL: [Self; 6] = [
Self::Ingested,
Self::QualityAssessed,
Self::SubstituteGenerated,
Self::Corrected,
Self::Archived,
Self::Anonymised,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ingested => "INGESTED",
Self::QualityAssessed => "QUALITY_ASSESSED",
Self::SubstituteGenerated => "SUBSTITUTE_GENERATED",
Self::Corrected => "CORRECTED",
Self::Archived => "ARCHIVED",
Self::Anonymised => "ANONYMISED",
}
}
}
crate::codes::string_codes! {
ProvenanceEventType;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeasurementSeries {
pub malo_id: MaloId,
pub melo_id: Option<MeloId>,
pub obis_code: Option<ObisCode>,
pub resolution: Option<IntervalResolution>,
pub source: MeasurementSource,
pub intervals: Vec<MeterInterval>,
pub provenance: Vec<ProvenanceEntry>,
}
impl MeasurementSeries {
#[must_use]
pub fn new(
malo_id: MaloId,
obis_code: Option<ObisCode>,
intervals: Vec<MeterInterval>,
source: MeasurementSource,
ingested_at: OffsetDateTime,
) -> Self {
let resolution = obis_code.and_then(|o| o.default_resolution());
let actor = source.label().to_owned();
Self {
malo_id,
melo_id: None,
obis_code,
resolution,
source,
intervals,
provenance: vec![ProvenanceEntry {
occurred_at: ingested_at,
event_type: ProvenanceEventType::Ingested,
actor,
note: None,
}],
}
}
#[must_use]
pub fn with_melo_id(mut self, melo_id: MeloId) -> Self {
self.melo_id = Some(melo_id);
self
}
#[must_use]
pub const fn with_resolution(mut self, resolution: IntervalResolution) -> Self {
self.resolution = Some(resolution);
self
}
#[must_use]
pub fn worst_quality(&self) -> QualityFlag {
QualityFlag::worst_of(self.intervals.iter().map(|iv| iv.quality))
}
#[must_use]
pub fn has_unbillable_intervals(&self) -> bool {
self.intervals.iter().any(|iv| !iv.quality.is_billable())
}
#[must_use]
pub fn interval_count(&self) -> usize {
self.intervals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.intervals.is_empty()
}
#[must_use]
pub fn total(&self) -> rust_decimal::Decimal {
self.intervals
.iter()
.filter(|iv| iv.quality.is_billable())
.map(|iv| iv.value)
.sum()
}
#[must_use]
pub fn period_from(&self) -> Option<OffsetDateTime> {
self.intervals.iter().map(|iv| iv.from).min()
}
#[must_use]
pub fn period_to(&self) -> Option<OffsetDateTime> {
self.intervals.iter().map(|iv| iv.to).max()
}
pub fn record_event(
&mut self,
event_type: ProvenanceEventType,
actor: impl Into<String>,
occurred_at: OffsetDateTime,
) {
self.provenance.push(ProvenanceEntry {
occurred_at,
event_type,
actor: actor.into(),
note: None,
});
}
pub fn record_event_with_note(
&mut self,
event_type: ProvenanceEventType,
actor: impl Into<String>,
occurred_at: OffsetDateTime,
note: impl Into<String>,
) {
self.provenance.push(ProvenanceEntry {
occurred_at,
event_type,
actor: actor.into(),
note: Some(note.into()),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
use crate::obis::ObisCode;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
use uuid::Uuid;
const INGEST: OffsetDateTime = datetime!(2026-01-02 09:30 UTC);
fn make_interval(from: OffsetDateTime, kwh: rust_decimal::Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + Duration::minutes(15),
value: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
fn malo() -> MaloId {
"51238696781".parse().unwrap()
}
fn mscons() -> MeasurementSource {
MeasurementSource::Mscons {
pid: 13005,
message_ref: None,
sender_mp_id: "9900357000004".to_owned(),
}
}
#[test]
fn worst_quality_is_derived_from_the_intervals() {
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 mut series = MeasurementSeries::new(
malo(),
Some(ObisCode::STROM_BEZUG_TOTAL),
intervals,
mscons(),
INGEST,
);
assert_eq!(series.worst_quality(), QualityFlag::Estimated);
assert_eq!(series.interval_count(), 2);
series.intervals.push({
let mut iv = make_interval(base + Duration::minutes(30), dec!(1.0));
iv.quality = QualityFlag::Faulty;
iv
});
assert_eq!(series.worst_quality(), QualityFlag::Faulty);
assert!(series.has_unbillable_intervals());
}
#[test]
fn empty_series_has_unknown_worst_quality() {
let series = MeasurementSeries::new(malo(), None, vec![], mscons(), INGEST);
assert_eq!(series.worst_quality(), QualityFlag::Unknown);
assert!(
!series.has_unbillable_intervals(),
"no intervals, no faults"
);
}
#[test]
fn construction_is_deterministic() {
let intervals = vec![make_interval(datetime!(2026-01-01 0:00 UTC), dec!(1.0))];
let a = MeasurementSeries::new(malo(), None, intervals.clone(), mscons(), INGEST);
let b = MeasurementSeries::new(malo(), None, intervals, mscons(), INGEST);
assert_eq!(a, b, "equal inputs must give equal series");
assert_eq!(a.provenance[0].occurred_at, INGEST);
assert_eq!(a.provenance[0].event_type, ProvenanceEventType::Ingested);
}
#[test]
fn recorded_events_append_in_the_order_given() {
let mut series = MeasurementSeries::new(malo(), None, vec![], mscons(), INGEST);
let later = INGEST + Duration::hours(3);
series.record_event(ProvenanceEventType::QualityAssessed, "validator", later);
series.record_event_with_note(
ProvenanceEventType::Corrected,
"ops-001",
later + Duration::hours(1),
"Zählerwechsel nachgetragen",
);
assert_eq!(series.provenance.len(), 3);
assert_eq!(series.provenance[1].occurred_at, later);
assert_eq!(series.provenance[1].actor, "validator");
assert_eq!(
series.provenance[2].note.as_deref(),
Some("Zählerwechsel nachgetragen")
);
}
#[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;
let series = MeasurementSeries::new(
malo(),
None,
intervals,
MeasurementSource::ManualEntry {
operator_id: "ops-001".to_owned(),
reason: "test".to_owned(),
},
INGEST,
);
assert_eq!(series.total(), dec!(2.0)); }
#[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(
malo(),
None,
intervals,
MeasurementSource::SmgwDirectPush {
device_id: "SMGW-001".to_owned(),
session_id: "sess-001".to_owned(),
},
INGEST,
);
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(
malo(),
None,
vec![],
MeasurementSource::AutoSubstitute {
method: crate::substitute::SubstituteMethod::ZeroFill,
reason: crate::substitute::SubstitutionReason::NoMeasurementAvailable,
},
INGEST,
);
assert!(series.is_empty());
assert_eq!(series.total(), rust_decimal::Decimal::ZERO);
assert!(series.period_from().is_none());
}
#[test]
fn builders_set_optional_context() {
let series = MeasurementSeries::new(malo(), None, vec![], mscons(), INGEST)
.with_melo_id("DE00056266802AO6G56M11SN51G21M24S".parse().unwrap())
.with_resolution(IntervalResolution::Hour);
assert_eq!(
series.melo_id.as_ref().map(MeloId::as_str),
Some("DE00056266802AO6G56M11SN51G21M24S")
);
assert_eq!(series.resolution, Some(IntervalResolution::Hour));
}
#[test]
fn source_labels_non_empty() {
let sources = [
mscons(),
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::nil(),
corrected_by: "op".into(),
},
MeasurementSource::VirtualMeter {
rule: VirtualMeterKind::Sum,
source_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(
malo(),
Some(ObisCode::STROM_BEZUG_TOTAL),
vec![],
mscons(),
INGEST,
);
assert_eq!(series.resolution, Some(IntervalResolution::QuarterHour));
}
}