metering 0.17.0

German energy metering domain library — DST-correct Europe/Berlin calendar arithmetic, gas m3→kWh_Hs conversion, billing-period aggregation, Ersatzwertbildung, a robust validation engine, Bundesland holiday calendar and SLP/RLM/iMSys classification. Zero I/O, no async, no clock, exact decimal quantities.
Documentation
//! Meter lifecycle events: installation, exchange, and retirement.
//!
//! ## Legal basis
//!
//! - **WiM Gerätewechsel-Dokumentation**: Messstellenbetreiber must document meter exchanges.
//! - **§14 MsbG**: Meter data must be available at supply handover.
//! - **BDEW GPKE**: Zählerwechsel triggers a Sonderablesung (INSRPT PID 23003).
//!
//! ## Why this matters for billing
//!
//! When a meter is replaced mid-period, two separate readings exist:
//! - **Old meter**: last reading before exchange
//! - **New meter**: first reading after exchange
//!
//! The `MeterExchangeEvent` anchors both readings to a single point in time,
//! enabling correct Mehr-/Mindermengensaldo calculation and billing continuity.

use rust_decimal::Decimal;
use time::{Date, OffsetDateTime};

use crate::reading::{Anomaly, LastgangConfig, MeterReading, consumption_between};

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

/// Status of a physical meter at a delivery point.
#[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 MeterStatus {
    /// Meter is installed and in service.
    #[default]
    Active,
    /// Meter has been removed / decommissioned.
    Removed,
    /// Meter is installed but not yet commissioned.
    Pending,
    /// Meter was tested in lab; now deployed to a MeLo.
    Deployed,
}

/// Type of lifecycle event affecting a physical meter.
#[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 MeterLifecycleEventType {
    /// Initial installation at a delivery point.
    Installed,
    /// Meter replaced with a new unit at the same delivery point.
    Replaced,
    /// Meter removed without replacement (end of supply).
    Removed,
    /// Meter firmware or calibration updated (SMGW update, new Eichung).
    Updated,
    /// Meter sealed / calibration renewed (Eichung, §28 MessEG).
    Recalibrated,
}

/// A lifecycle event for a physical meter.
///
/// Stored as an immutable audit log — never updated, only appended.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterLifecycleEvent {
    /// Unique event identifier.
    pub event_id: String,
    /// Meter serial number (Zählernummer / Gerätenummer).
    pub meter_serial: String,
    /// 33-character Messlokations-ID of the delivery point.
    pub melo_id: String,
    /// Type of lifecycle event.
    pub event_type: MeterLifecycleEventType,
    /// When the event occurred (UTC).
    pub occurred_at: OffsetDateTime,
    /// The meter reading at the time of the event (kWh).
    /// `None` when not applicable (e.g., firmware update).
    pub reading: Option<Decimal>,
    /// OBIS code of the reading (when `reading` is set).
    pub obis_code: Option<crate::obis::ObisCode>,
    /// Free-text reason / operator note.
    pub reason: Option<String>,
    /// BDEW PID that triggered this event (e.g., 23003 for Zählerwechsel).
    pub triggered_by_pid: Option<u32>,
}

/// A meter exchange event: the old meter is replaced by a new one.
///
/// ## Relationship to [`crate::reading`]
///
/// The helpers below difference register readings, which is
/// [`reading::consumption_between`](crate::reading::consumption_between)'s job
/// in general. They stay here because an exchange is the one case that needs
/// **two** registers: the difference cannot be taken across the boundary,
/// because the new meter starts over. What this type adds is the pairing of the
/// old meter's final reading with the new one's first.
///
/// An exchange is also the likeliest explanation for a backwards step that
/// [`reading::to_lastgang`](crate::reading::to_lastgang) refuses to read as a
/// register wrap — see [`AnomalyKind::ImplausibleRollover`].
///
/// [`AnomalyKind::ImplausibleRollover`]: crate::reading::AnomalyKind::ImplausibleRollover
///
/// ## Billing continuity
///
/// The pair `(old_reading, new_reading)` at `exchange_at` enables seamless
/// computation of consumption across the exchange boundary:
///
/// ```text
/// consumption_before = old_reading_kwh − period_start_reading
/// consumption_after  = period_end_reading − new_first_reading
/// total_period       = consumption_before + consumption_after
/// ```
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterExchangeEvent {
    /// Unique exchange identifier.
    pub exchange_id: String,
    /// 33-character MeLo where the exchange took place.
    pub melo_id: String,
    /// Serial number of the removed meter.
    pub old_meter_serial: String,
    /// Final reading of the old meter (kWh).
    pub old_final_reading: Decimal,
    /// Serial number of the newly installed meter.
    pub new_meter_serial: String,
    /// First reading of the new meter (kWh).
    pub new_first_reading: Decimal,
    /// Date and time of the exchange (UTC).
    pub exchange_at: OffsetDateTime,
    /// Calendar date of the exchange (for billing period alignment).
    pub exchange_date: Date,
    /// BDEW PID that triggered this exchange (typically 23003).
    pub triggered_by_pid: Option<u32>,
    /// INSRPT process ID that reported this exchange.
    pub insrpt_process_id: Option<String>,
    /// Technician or system that performed the exchange.
    pub performed_by: Option<String>,
}

impl MeterExchangeEvent {
    /// The old meter's final reading, as a [`MeterReading`] at the exchange
    /// instant.
    #[must_use]
    pub fn old_final(&self) -> MeterReading {
        MeterReading::measured(self.exchange_at, self.old_final_reading)
    }

    /// The new meter's first reading, as a [`MeterReading`] at the exchange
    /// instant.
    #[must_use]
    pub fn new_first(&self) -> MeterReading {
        MeterReading::measured(self.exchange_at, self.new_first_reading)
    }

    /// Consumption on the **old** meter from `period_start` to the exchange.
    ///
    /// # Errors
    ///
    /// Returns the [`Anomaly`] when no honest difference exists — see
    /// [`consumption_between`].
    ///
    /// These three methods used to return a bare `Decimal` and clamp a
    /// backwards step to zero, under the name *"rollover protection"*. It was
    /// the opposite: a Jahresabrechnung whose old register had **wrapped**
    /// during the period billed **0 kWh** for the whole pre-exchange span, and
    /// nothing anywhere reported it. Reconstructing the wrap is what
    /// [`LastgangConfig::register_digits`] is for, and refusing to guess is
    /// what the `Result` is for.
    pub fn consumption_old_meter(
        &self,
        period_start: &MeterReading,
        config: &LastgangConfig,
    ) -> Result<Decimal, Anomaly> {
        consumption_between(period_start, &self.old_final(), config)
    }

    /// Consumption on the **new** meter from the exchange to `period_end`.
    ///
    /// # Errors
    ///
    /// As [`consumption_old_meter`](Self::consumption_old_meter).
    pub fn consumption_new_meter(
        &self,
        period_end: &MeterReading,
        config: &LastgangConfig,
    ) -> Result<Decimal, Anomaly> {
        consumption_between(&self.new_first(), period_end, config)
    }

    /// Total consumption across the exchange boundary.
    ///
    /// The two meters are differenced separately and summed — the readings
    /// cannot be subtracted across the boundary, because the new register
    /// starts over. That is the whole reason this type exists.
    ///
    /// # Errors
    ///
    /// The first [`Anomaly`] either side produces; a failure on one meter makes
    /// the total unusable whatever the other one says.
    pub fn total_consumption(
        &self,
        period_start: &MeterReading,
        period_end: &MeterReading,
        config: &LastgangConfig,
    ) -> Result<Decimal, Anomaly> {
        Ok(self.consumption_old_meter(period_start, config)?
            + self.consumption_new_meter(period_end, config)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rust_decimal::dec;
    use time::macros::{date, datetime};

    fn make_exchange() -> MeterExchangeEvent {
        MeterExchangeEvent {
            exchange_id: "EX-001".to_owned(),
            melo_id: "DE000123456789".to_owned(),
            old_meter_serial: "OLD-1234".to_owned(),
            old_final_reading: dec!(12500),
            new_meter_serial: "NEW-5678".to_owned(),
            new_first_reading: dec!(0), // new meter starts at 0
            exchange_at: datetime!(2026-06-15 8:00 UTC),
            exchange_date: date!(2026 - 06 - 15),
            triggered_by_pid: Some(23003),
            insrpt_process_id: None,
            performed_by: Some("MSB-9900357000004".to_owned()),
        }
    }

    fn start(value: Decimal) -> MeterReading {
        MeterReading::measured(datetime!(2026-06-01 0:00 UTC), value)
    }

    fn end(value: Decimal) -> MeterReading {
        MeterReading::measured(datetime!(2026-06-30 0:00 UTC), value)
    }

    #[test]
    fn consumption_split_across_exchange() {
        let ex = make_exchange();
        let cfg = LastgangConfig::default();
        // Old meter: 12 000 on 1 June, 12 500 at the exchange → 500 kWh.
        // New meter: 0 at the exchange, 800 on 30 June → 800 kWh.
        assert_eq!(
            ex.consumption_old_meter(&start(dec!(12000)), &cfg),
            Ok(dec!(500))
        );
        assert_eq!(
            ex.consumption_new_meter(&end(dec!(800)), &cfg),
            Ok(dec!(800))
        );
        assert_eq!(
            ex.total_consumption(&start(dec!(12000)), &end(dec!(800)), &cfg),
            Ok(dec!(1300))
        );
    }

    /// The bug the old `.max(ZERO)` hid: a register that wrapped during the
    /// period billed **zero** for the whole pre-exchange span.
    #[test]
    fn a_wrapped_old_register_is_reconstructed_not_zeroed() {
        use crate::reading::AnomalyKind;

        let mut ex = make_exchange();
        ex.old_final_reading = dec!(300); // six-digit register wrapped past 999 999
        let period_start = start(dec!(999_500));

        // Without a register width there is no honest answer, and the caller is
        // told so instead of being handed a zero.
        let blind = ex.consumption_old_meter(&period_start, &LastgangConfig::default());
        assert_eq!(
            blind.unwrap_err().kind,
            AnomalyKind::BackwardsWithoutRegisterWidth
        );

        // With one, the wrap is reconstructed: (1 000 000 − 999 500) + 300.
        let cfg = LastgangConfig::default().with_register_digits(6);
        assert_eq!(ex.consumption_old_meter(&period_start, &cfg), Ok(dec!(800)));
    }

    /// A transposed pair — period start after the final reading — is an error,
    /// not zero consumption.
    #[test]
    fn a_backwards_reading_is_reported_rather_than_zeroed() {
        let ex = make_exchange();
        let err = ex
            .consumption_old_meter(&start(dec!(13000)), &LastgangConfig::default())
            .unwrap_err();
        assert!(err.to_string().contains("register decreased"), "{err}");
    }

    /// A failure on either meter makes the total unusable.
    #[test]
    fn the_total_fails_when_either_side_does() {
        let ex = make_exchange();
        let cfg = LastgangConfig::default();
        assert!(
            ex.total_consumption(&start(dec!(13000)), &end(dec!(800)), &cfg)
                .is_err()
        );
        assert!(
            ex.total_consumption(&start(dec!(12000)), &end(dec!(-5)), &cfg)
                .is_err()
        );
    }
}