metering 0.12.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
//! Core metering types: [`MeterInterval`], [`Sparte`], [`QualityFlag`].

use rust_decimal::Decimal;
use time::OffsetDateTime;

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

/// Energy commodity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Sparte {
    /// Electricity.
    #[default]
    Strom,
    /// Natural gas.
    Gas,
}

/// BDEW / MSCONS quality flag.
///
/// Maps to the `MESSWERTSTATUS` field in MSCONS and
/// the BO4E `Messwertstatus` enum.
///
/// ## Billability (§17 MessZV)
///
/// §17 MessZV requires substitute values (Ersatzwerte) when measurements are
/// absent. An estimated value (Prognosewert) IS a valid billing basis for
/// advance payments and Abschlagsrechnung:
///
/// | Flag | Billable | Notes |
/// |---|---|---|
/// | `Measured` | ✓ | Actual reading — highest confidence |
/// | `Estimated` | ✓ | Prognosewert — valid for advance billing (§17 MessZV) |
/// | `Substituted` | ✓ | Ersatzwert — replacement by MSB when measurement failed |
/// | `Calculated` | ✓ | Derived from other measurements (e.g. Residuallast) |
/// | `Corrected` | ✓ | Nachbearbeitet — corrected from an earlier value |
/// | `Preliminary` | ✓ | Vorläufiger Wert — may be revised later |
/// | `Faulty` | ✗ | Fehlerhaft — measurement error, must not be billed |
/// | `Unknown` | ✗ | Quality not determinable — do not bill |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum QualityFlag {
    /// Reading as measured (Abgelesen / Messwert).
    Measured,
    /// Estimated value (Prognosewert) — valid for advance billing per §17 MessZV.
    ///
    /// Used in Abschlagsrechnung before final annual read.
    /// Also generated by SLP profiling for customers without interval metering.
    Estimated,
    /// Substituted / replaced value (Ersatzwert).
    ///
    /// Generated by the Messstellenbetreiber when measurement failed.
    /// §17 MessZV: must be used for billing when measurement unavailable.
    Substituted,
    /// Calculated / derived value (Vorlaeufiger Wert / Rechenwert).
    ///
    /// Derived from other meter readings (e.g. Residuallast = Bezug − Einspeisung).
    Calculated,
    /// Corrected value (Nachbearbeitungswert).
    ///
    /// Originally measured/estimated, subsequently corrected by MSB.
    /// Replaces a prior value — billable, supersedes earlier reading.
    Corrected,
    /// Preliminary value (Vorläufiger Wert).
    ///
    /// Valid for billing but may be revised. Record as provisional in invoice.
    Preliminary,
    /// Faulty measurement (Fehlerhaft / Unplausibel).
    ///
    /// Must NOT be used for billing. Requires substitute value generation.
    Faulty,
    /// Quality not known.
    #[default]
    Unknown,
}

impl QualityFlag {
    /// `true` when this flag indicates the value is reliable for billing.
    ///
    /// ## §17 MessZV note
    ///
    /// `Estimated` (Prognosewert) IS billable — it is the statutory mechanism
    /// for advance billing before the actual annual read is available.
    /// Excluding estimated values from billing would produce zero arbeitsmenge
    /// for SLP customers and during measurement outages, which is wrong.
    #[must_use]
    pub fn is_billable(&self) -> bool {
        matches!(
            self,
            QualityFlag::Measured
                | QualityFlag::Estimated
                | QualityFlag::Substituted
                | QualityFlag::Calculated
                | QualityFlag::Corrected
                | QualityFlag::Preliminary
        )
    }

    /// `true` when this value should be flagged as provisional in invoices.
    ///
    /// Preliminary values are billable but the invoice should note they may be revised.
    #[must_use]
    pub fn is_provisional(&self) -> bool {
        matches!(self, QualityFlag::Preliminary | QualityFlag::Estimated)
    }
}

/// A single metered interval: the fundamental unit of meter data.
///
/// All energy values are in **kWh** (Strom) or **kWh_Hs** (Gas after conversion).
/// Use [`crate::conversion::gas_m3_to_kwh_hs`] to convert Gas m³ readings before
/// creating `MeterInterval`s.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterInterval {
    /// Interval start (UTC, inclusive).
    pub from: OffsetDateTime,
    /// Interval end (UTC, exclusive).
    pub to: OffsetDateTime,
    /// Energy quantity in kWh (or kWh_Hs for Gas).
    pub value_kwh: Decimal,
    /// Reading quality.
    pub quality: QualityFlag,
    /// OBIS-Kennzahl (e.g. `"1-0:1.8.0"`). `None` when not provided by MSCONS.
    pub obis_code: Option<String>,
}

impl MeterInterval {
    /// Duration in whole seconds.
    #[must_use]
    pub fn duration_secs(&self) -> i64 {
        (self.to - self.from).whole_seconds()
    }

    /// Duration in minutes.
    #[must_use]
    pub fn duration_minutes(&self) -> i64 {
        (self.to - self.from).whole_minutes()
    }

    /// Instantaneous demand in kW, computed as `kWh ÷ (duration_h)`.
    ///
    /// Only meaningful for RLM intervals (15-min or 60-min).
    /// For a 15-min interval carrying 2.5 kWh: demand = 2.5 × 4 = 10 kW.
    #[must_use]
    pub fn demand_kw(&self) -> Option<Decimal> {
        let h = Decimal::from(self.duration_secs()) / Decimal::from(3600u32);
        if h.is_zero() {
            None
        } else {
            Some(self.value_kwh / h)
        }
    }

    /// Parse the `obis_code` string into a typed [`crate::obis::ObisCode`].
    ///
    /// Returns `None` when `obis_code` is absent or cannot be parsed.
    /// The raw `obis_code: Option<String>` is preserved for EDIFACT round-trips.
    ///
    /// ## Example
    ///
    /// ```rust
    /// use metering::{MeterInterval, QualityFlag};
    /// use metering::obis::ObisCode;
    /// use rust_decimal_macros::dec;
    /// use time::macros::datetime;
    ///
    /// let iv = MeterInterval {
    ///     from: datetime!(2026-01-01 0:00 UTC),
    ///     to:   datetime!(2026-01-01 0:15 UTC),
    ///     value_kwh: dec!(2.5),
    ///     quality: QualityFlag::Measured,
    ///     obis_code: Some("1-0:1.8.0*255".to_owned()),
    /// };
    /// let code = iv.parsed_obis_code().unwrap();
    /// assert_eq!(code, ObisCode::STROM_BEZUG_TOTAL);
    /// ```
    #[must_use]
    pub fn parsed_obis_code(&self) -> Option<crate::obis::ObisCode> {
        self.obis_code.as_deref()?.parse().ok()
    }

    /// `true` when this interval carries forward / import energy (D = 8 in the OBIS code).
    ///
    /// Returns `None` when no OBIS code is set or the code is unparseable.
    #[must_use]
    pub fn is_import_energy(&self) -> Option<bool> {
        self.parsed_obis_code().map(|c| c.is_import())
    }

    /// `true` when this interval carries reverse / export (Einspeisung) energy (D = 9).
    #[must_use]
    pub fn is_export_energy(&self) -> Option<bool> {
        self.parsed_obis_code().map(|c| c.is_export())
    }

    /// Tariff register number from the OBIS code (`None` = total, `Some(1)` = HT, `Some(2)` = NT).
    #[must_use]
    pub fn tariff_register(&self) -> Option<u8> {
        self.parsed_obis_code()?.tariff_register()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal_macros::dec;
    use time::macros::datetime;

    #[test]
    fn demand_kw_15min_interval() {
        let iv = MeterInterval {
            from: datetime!(2026-01-01 0:00 UTC),
            to: datetime!(2026-01-01 0:15 UTC),
            value_kwh: dec!(2.5),
            quality: QualityFlag::Measured,
            obis_code: None,
        };
        // 2.5 kWh in 15 min = 10 kW
        assert_eq!(iv.demand_kw(), Some(dec!(10)));
    }

    #[test]
    fn demand_kw_hourly() {
        let iv = MeterInterval {
            from: datetime!(2026-01-01 0:00 UTC),
            to: datetime!(2026-01-01 1:00 UTC),
            value_kwh: dec!(5.0),
            quality: QualityFlag::Measured,
            obis_code: None,
        };
        // 5.0 kWh in 60 min = 5 kW
        assert_eq!(iv.demand_kw(), Some(dec!(5)));
    }

    #[test]
    fn quality_flag_billable() {
        // §17 MessZV: Measured, Estimated, Substituted, Calculated, Corrected, Preliminary → all billable
        assert!(QualityFlag::Measured.is_billable());
        assert!(QualityFlag::Substituted.is_billable());
        // CRITICAL FIX: Estimated (Prognosewert) IS billable per §17 MessZV — used in Abschlagsrechnung
        assert!(
            QualityFlag::Estimated.is_billable(),
            "Estimated must be billable per §17 MessZV"
        );
        assert!(QualityFlag::Corrected.is_billable());
        assert!(QualityFlag::Preliminary.is_billable());
        // Only Faulty and Unknown block billing
        assert!(!QualityFlag::Faulty.is_billable());
        assert!(!QualityFlag::Unknown.is_billable());
    }

    #[test]
    fn quality_flag_provisional() {
        assert!(QualityFlag::Estimated.is_provisional());
        assert!(QualityFlag::Preliminary.is_provisional());
        assert!(!QualityFlag::Measured.is_provisional());
        assert!(!QualityFlag::Substituted.is_provisional());
    }
}