use rust_decimal::Decimal;
use time::{Date, OffsetDateTime};
use crate::ids::MeloId;
use crate::reading::{Anomaly, LastgangConfig, MeterReading, consumption_between};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[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 {
#[default]
Active,
Removed,
Pending,
Deployed,
}
impl MeterStatus {
pub const ALL: [Self; 4] = [Self::Active, Self::Removed, Self::Pending, Self::Deployed];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "ACTIVE",
Self::Removed => "REMOVED",
Self::Pending => "PENDING",
Self::Deployed => "DEPLOYED",
}
}
#[must_use]
pub const fn is_in_service(self) -> bool {
matches!(self, Self::Active)
}
}
#[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 {
Installed,
Replaced,
Removed,
Updated,
Recalibrated,
}
impl MeterLifecycleEventType {
pub const ALL: [Self; 5] = [
Self::Installed,
Self::Replaced,
Self::Removed,
Self::Updated,
Self::Recalibrated,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Installed => "INSTALLED",
Self::Replaced => "REPLACED",
Self::Removed => "REMOVED",
Self::Updated => "UPDATED",
Self::Recalibrated => "RECALIBRATED",
}
}
#[must_use]
pub const fn breaks_register_continuity(self) -> bool {
matches!(self, Self::Installed | Self::Replaced | Self::Removed)
}
}
crate::codes::string_codes! {
MeterStatus;
MeterLifecycleEventType;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterLifecycleEvent {
pub event_id: String,
pub meter_serial: String,
pub melo_id: MeloId,
pub event_type: MeterLifecycleEventType,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub occurred_at: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
pub reading: Option<Decimal>,
pub obis_code: Option<crate::obis::ObisCode>,
pub reason: Option<String>,
pub triggered_by_pid: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterExchangeEvent {
pub exchange_id: String,
pub melo_id: MeloId,
pub old_meter_serial: String,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub old_final_reading: Decimal,
pub new_meter_serial: String,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub new_first_reading: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub exchange_at: OffsetDateTime,
pub triggered_by_pid: Option<u32>,
pub insrpt_process_id: Option<String>,
pub performed_by: Option<String>,
}
impl MeterExchangeEvent {
#[must_use]
pub fn exchange_date(&self) -> Date {
crate::calendar::local_day(self.exchange_at)
}
#[must_use]
pub fn old_final(&self) -> MeterReading {
MeterReading::measured(self.exchange_at, self.old_final_reading)
}
#[must_use]
pub fn new_first(&self) -> MeterReading {
MeterReading::measured(self.exchange_at, self.new_first_reading)
}
pub fn consumption_old_meter(
&self,
period_start: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
consumption_between(period_start, &self.old_final(), config)
}
pub fn consumption_new_meter(
&self,
period_end: &MeterReading,
config: &LastgangConfig,
) -> Result<Decimal, Anomaly> {
consumption_between(&self.new_first(), period_end, config)
}
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: "DE00056266802AO6G56M11SN51G21M24S".parse().unwrap(),
old_meter_serial: "OLD-1234".to_owned(),
old_final_reading: dec!(12500),
new_meter_serial: "NEW-5678".to_owned(),
new_first_reading: dec!(0), exchange_at: datetime!(2026-06-15 8:00 UTC),
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();
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))
);
}
#[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); let period_start = start(dec!(999_500));
let blind = ex.consumption_old_meter(&period_start, &LastgangConfig::default());
assert_eq!(
blind.unwrap_err().kind,
AnomalyKind::BackwardsWithoutRegisterWidth
);
let cfg = LastgangConfig::default().with_register_digits(6);
assert_eq!(ex.consumption_old_meter(&period_start, &cfg), Ok(dec!(800)));
}
#[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}");
}
#[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()
);
}
#[test]
fn the_exchange_day_is_derived_from_the_instant() {
let mut ex = make_exchange();
assert_eq!(ex.exchange_date(), date!(2026 - 06 - 15));
ex.exchange_at = datetime!(2026-06-14 22:30 UTC); assert_eq!(
ex.exchange_date(),
date!(2026 - 06 - 15),
"the Berlin calendar day, not the UTC one"
);
assert_eq!(ex.exchange_at.date(), date!(2026 - 06 - 14));
}
#[test]
fn lifecycle_codes_round_trip() {
assert_eq!(MeterStatus::ALL.len(), MeterStatus::CODES.len());
for (v, code) in MeterStatus::ALL.iter().zip(MeterStatus::CODES) {
assert_eq!(v.as_str(), *code);
assert_eq!(v.to_string(), *code);
assert_eq!(&v.to_string().parse::<MeterStatus>().unwrap(), v);
}
assert_eq!(
MeterLifecycleEventType::ALL.len(),
MeterLifecycleEventType::CODES.len()
);
for (v, code) in MeterLifecycleEventType::ALL
.iter()
.zip(MeterLifecycleEventType::CODES)
{
assert_eq!(v.as_str(), *code);
assert_eq!(
&v.to_string().parse::<MeterLifecycleEventType>().unwrap(),
v
);
}
assert_eq!(
" active ".parse::<MeterStatus>().unwrap(),
MeterStatus::Active
);
assert!("GARBAGE".parse::<MeterStatus>().is_err());
assert!(MeterStatus::Active.is_in_service());
assert!(!MeterStatus::Removed.is_in_service());
assert!(MeterLifecycleEventType::Replaced.breaks_register_continuity());
assert!(!MeterLifecycleEventType::Updated.breaks_register_continuity());
assert!(!MeterLifecycleEventType::Recalibrated.breaks_register_continuity());
}
}