use std::fmt;
use std::str::FromStr;
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
use crate::obis::ObisCode;
#[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 {
#[default]
Strom,
Gas,
Waerme,
Wasser,
}
impl Sparte {
#[must_use]
pub const fn measured_unit(self) -> MeasurementUnit {
match self {
Self::Strom | Self::Waerme => MeasurementUnit::KiloWattHour,
Self::Gas | Self::Wasser => MeasurementUnit::CubicMetre,
}
}
#[must_use]
pub const fn billing_unit(self) -> MeasurementUnit {
match self {
Self::Strom | Self::Gas | Self::Waerme => MeasurementUnit::KiloWattHour,
Self::Wasser => MeasurementUnit::CubicMetre,
}
}
#[must_use]
pub const fn requires_conversion(self) -> bool {
matches!(
(self.measured_unit(), self.billing_unit()),
(MeasurementUnit::KiloWattHour, MeasurementUnit::CubicMetre)
| (MeasurementUnit::CubicMetre, MeasurementUnit::KiloWattHour)
)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Strom => "STROM",
Self::Gas => "GAS",
Self::Waerme => "WAERME",
Self::Wasser => "WASSER",
}
}
pub const ALL: [Self; 4] = [Self::Strom, Self::Gas, Self::Waerme, Self::Wasser];
}
crate::codes::string_codes! {
Sparte, aliases = [("WÄRME", Self::Waerme)];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum MeasurementUnit {
#[default]
#[cfg_attr(feature = "serde", serde(rename = "KWH"))]
KiloWattHour,
#[cfg_attr(feature = "serde", serde(rename = "M3"))]
CubicMetre,
}
impl MeasurementUnit {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::KiloWattHour => "KWH",
Self::CubicMetre => "M3",
}
}
pub const ALL: [Self; 2] = [Self::KiloWattHour, Self::CubicMetre];
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
let scaled = Self::parse_scaled(s)?;
scaled.is_canonical().then_some(scaled.unit)
}
#[must_use]
pub fn parse_scaled(s: &str) -> Option<UnitScale> {
let (unit, num, den) = match s.trim().to_lowercase().as_str() {
"kwh" | "kwh_th" | "kwh_hs" => (Self::KiloWattHour, 1, 1),
"wh" => (Self::KiloWattHour, 1, 1_000),
"mwh" => (Self::KiloWattHour, 1_000, 1),
"gwh" => (Self::KiloWattHour, 1_000_000, 1),
"gj" => (Self::KiloWattHour, 2_500, 9),
"mj" => (Self::KiloWattHour, 5, 18),
"m3" | "m³" | "cbm" => (Self::CubicMetre, 1, 1),
"l" | "ltr" | "liter" | "litre" => (Self::CubicMetre, 1, 1_000),
"mtq" => (Self::CubicMetre, 1, 1),
"whr" => (Self::KiloWattHour, 1, 1_000),
"gv" => (Self::KiloWattHour, 2_500, 9),
"3b" => (Self::KiloWattHour, 5, 18),
"jou" => (Self::KiloWattHour, 1, 3_600_000),
"kjo" => (Self::KiloWattHour, 1, 3_600),
_ => return None,
};
Some(UnitScale { unit, num, den })
}
}
impl fmt::Display for MeasurementUnit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl MeasurementUnit {
pub const CODES: &'static [&'static str] = &["KWH", "M3"];
}
impl FromStr for MeasurementUnit {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s).ok_or_else(|| ParseError::one_of("MeasurementUnit", s, Self::CODES))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitScale {
pub unit: MeasurementUnit,
pub num: i64,
pub den: i64,
}
impl UnitScale {
#[must_use]
pub const fn is_canonical(self) -> bool {
self.num == self.den
}
#[must_use]
pub fn apply(self, value: Decimal) -> Decimal {
if self.is_canonical() {
return value;
}
value * Decimal::from(self.num) / Decimal::from(self.den)
}
}
#[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 {
Measured,
Estimated,
Substituted,
Calculated,
Corrected,
Preliminary,
Faulty,
#[default]
Unknown,
}
impl QualityFlag {
#[must_use]
pub const fn is_billable(self) -> bool {
matches!(
self,
Self::Measured
| Self::Estimated
| Self::Substituted
| Self::Calculated
| Self::Corrected
| Self::Preliminary
)
}
#[must_use]
pub const fn is_provisional(self) -> bool {
matches!(self, Self::Preliminary | Self::Estimated)
}
#[must_use]
pub const fn severity_rank(self) -> u8 {
match self {
Self::Measured => 0,
Self::Calculated => 1,
Self::Corrected => 2,
Self::Substituted => 3,
Self::Estimated => 4,
Self::Preliminary => 5,
Self::Faulty => 6,
Self::Unknown => 7,
}
}
#[must_use]
pub const fn worse_of(self, other: Self) -> Self {
if other.severity_rank() > self.severity_rank() {
other
} else {
self
}
}
#[must_use]
pub fn worst_of(flags: impl IntoIterator<Item = Self>) -> Self {
flags
.into_iter()
.reduce(Self::worse_of)
.unwrap_or(Self::Unknown)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Measured => "MEASURED",
Self::Estimated => "ESTIMATED",
Self::Substituted => "SUBSTITUTED",
Self::Calculated => "CALCULATED",
Self::Corrected => "CORRECTED",
Self::Preliminary => "PRELIMINARY",
Self::Faulty => "FAULTY",
Self::Unknown => "UNKNOWN",
}
}
pub const ALL: [Self; 8] = [
Self::Measured,
Self::Estimated,
Self::Substituted,
Self::Calculated,
Self::Corrected,
Self::Preliminary,
Self::Faulty,
Self::Unknown,
];
}
crate::codes::string_codes! {
QualityFlag;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterInterval {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
pub value: Decimal,
pub quality: QualityFlag,
pub obis_code: Option<ObisCode>,
}
impl MeterInterval {
#[must_use]
pub fn duration_secs(&self) -> i64 {
(self.to - self.from).whole_seconds()
}
#[must_use]
pub fn berlin_day(&self) -> time::Date {
crate::calendar::local_day(self.from)
}
#[must_use]
pub fn duration_minutes(&self) -> i64 {
(self.to - self.from).whole_minutes()
}
#[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 / h)
}
}
#[must_use]
pub fn is_import_energy(&self) -> Option<bool> {
self.obis_code.map(|c| c.is_import())
}
#[must_use]
pub fn is_export_energy(&self) -> Option<bool> {
self.obis_code.map(|c| c.is_export())
}
#[must_use]
pub fn tariff_register(&self) -> Option<u8> {
self.obis_code?.tariff_register()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::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: dec!(2.5),
quality: QualityFlag::Measured,
obis_code: None,
};
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: dec!(5.0),
quality: QualityFlag::Measured,
obis_code: None,
};
assert_eq!(iv.demand_kw(), Some(dec!(5)));
}
#[test]
fn quality_flag_billable() {
assert!(QualityFlag::Measured.is_billable());
assert!(QualityFlag::Substituted.is_billable());
assert!(
QualityFlag::Estimated.is_billable(),
"Estimated must be billable"
);
assert!(QualityFlag::Corrected.is_billable());
assert!(QualityFlag::Preliminary.is_billable());
assert!(!QualityFlag::Faulty.is_billable());
assert!(!QualityFlag::Unknown.is_billable());
}
#[test]
fn severity_ranks_are_a_strict_total_order() {
let ranks: Vec<u8> = QualityFlag::ALL.iter().map(|q| q.severity_rank()).collect();
let unique: std::collections::BTreeSet<u8> = ranks.iter().copied().collect();
assert_eq!(
unique.len(),
QualityFlag::ALL.len(),
"every flag needs its own rank, or worse_of breaks ties by argument order: {ranks:?}"
);
for a in QualityFlag::ALL {
for b in QualityFlag::ALL {
assert_eq!(a.worse_of(b), b.worse_of(a), "{a} vs {b}");
}
}
let set = QualityFlag::ALL;
let mut reversed = set;
reversed.reverse();
assert_eq!(QualityFlag::worst_of(set), QualityFlag::worst_of(reversed));
assert_eq!(QualityFlag::worst_of(set), QualityFlag::Unknown);
assert_eq!(QualityFlag::worst_of([]), QualityFlag::Unknown);
assert_eq!(QualityFlag::Measured.severity_rank(), 0);
assert!(
QualityFlag::Corrected.severity_rank() < QualityFlag::Substituted.severity_rank(),
"a corrected value has a measurement behind it; a substitute does not"
);
}
#[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());
}
}
#[cfg(test)]
mod media_tests {
use super::*;
#[test]
fn measured_unit_is_what_the_register_counts() {
assert_eq!(Sparte::Gas.measured_unit(), MeasurementUnit::CubicMetre);
assert_eq!(Sparte::Wasser.measured_unit(), MeasurementUnit::CubicMetre);
assert_eq!(
Sparte::Waerme.measured_unit(),
MeasurementUnit::KiloWattHour
);
assert_eq!(Sparte::Strom.measured_unit(), MeasurementUnit::KiloWattHour);
}
#[test]
fn billing_unit_diverges_from_measured_unit_only_for_gas() {
for sparte in [Sparte::Strom, Sparte::Gas, Sparte::Waerme, Sparte::Wasser] {
assert_eq!(
sparte.requires_conversion(),
sparte == Sparte::Gas,
"{} conversion requirement",
sparte.as_str()
);
assert_eq!(
sparte.measured_unit() != sparte.billing_unit(),
sparte.requires_conversion(),
"{}: requires_conversion must track the unit divergence",
sparte.as_str()
);
}
assert_eq!(Sparte::Wasser.billing_unit(), MeasurementUnit::CubicMetre);
assert_eq!(Sparte::Gas.billing_unit(), MeasurementUnit::KiloWattHour);
}
#[test]
fn unit_parse_accepts_superscript_cubic_metre() {
assert_eq!(
MeasurementUnit::parse("m³"),
Some(MeasurementUnit::CubicMetre)
);
assert_eq!(
MeasurementUnit::parse("m3"),
Some(MeasurementUnit::CubicMetre)
);
assert_eq!(
MeasurementUnit::parse(" M3 "),
Some(MeasurementUnit::CubicMetre)
);
assert_eq!(
MeasurementUnit::parse("kWh_th"),
Some(MeasurementUnit::KiloWattHour)
);
assert_eq!(MeasurementUnit::parse("furlong"), None);
}
#[test]
fn strict_parse_rejects_units_needing_rescaling() {
assert_eq!(MeasurementUnit::parse("MWh"), None);
assert_eq!(MeasurementUnit::parse("GJ"), None);
assert_eq!(MeasurementUnit::parse("l"), None);
assert!(MeasurementUnit::parse_scaled("MWh").is_some());
assert!(MeasurementUnit::parse_scaled("GJ").is_some());
}
#[test]
fn gigajoule_conversion_is_exact() {
let gj = MeasurementUnit::parse_scaled("GJ").unwrap();
assert_eq!(gj.unit, MeasurementUnit::KiloWattHour);
assert!(!gj.is_canonical());
let kwh = gj.apply(Decimal::from_str_exact("3.6").unwrap());
assert_eq!(
kwh,
Decimal::from(1000u32),
"3.6 GJ must be exactly 1000 kWh"
);
assert_eq!(gj.apply(Decimal::from(9u32)), Decimal::from(2500u32));
}
#[test]
fn unece_rec20_codes_do_not_follow_the_obvious_mnemonic() {
let kwh = MeasurementUnit::KiloWattHour;
let gv = MeasurementUnit::parse_scaled("GV").unwrap();
assert_eq!((gv.unit, gv.num, gv.den), (kwh, 2_500, 9));
let mj = MeasurementUnit::parse_scaled("3B").unwrap();
assert_eq!((mj.unit, mj.num, mj.den), (kwh, 5, 18));
assert_eq!(mj, MeasurementUnit::parse_scaled("MJ").unwrap());
assert_eq!(
MeasurementUnit::parse_scaled("MTQ").unwrap().unit,
MeasurementUnit::CubicMetre
);
let jou = MeasurementUnit::parse_scaled("JOU").unwrap();
assert_eq!(jou.apply(Decimal::from(3_600_000u32)), Decimal::ONE);
}
#[test]
fn scaled_units_cover_the_real_device_population() {
let cases = [
("MWh", "0.5", "500"), ("Wh", "2500", "2.5"),
("MJ", "18", "5"), ("l", "1500", "1.5"), ("kWh", "42", "42"), ];
for (unit, input, expected) in cases {
let scale =
MeasurementUnit::parse_scaled(unit).unwrap_or_else(|| panic!("{unit} must parse"));
assert_eq!(
scale.apply(Decimal::from_str_exact(input).unwrap()),
Decimal::from_str_exact(expected).unwrap(),
"{input} {unit}"
);
}
}
#[test]
fn labels_match_the_db_check_values() {
assert_eq!(Sparte::Waerme.as_str(), "WAERME");
assert_eq!(Sparte::Wasser.as_str(), "WASSER");
assert_eq!(MeasurementUnit::KiloWattHour.as_str(), "KWH");
assert_eq!(MeasurementUnit::CubicMetre.as_str(), "M3");
}
}
#[cfg(test)]
mod code_round_trip_tests {
use super::*;
#[test]
fn every_variant_round_trips() {
assert_eq!(Sparte::ALL.len(), Sparte::CODES.len());
for (v, code) in Sparte::ALL.iter().zip(Sparte::CODES) {
assert_eq!(v.as_str(), *code);
assert_eq!(v.to_string(), *code);
assert_eq!(&v.to_string().parse::<Sparte>().unwrap(), v);
}
assert_eq!(QualityFlag::ALL.len(), QualityFlag::CODES.len());
for (v, code) in QualityFlag::ALL.iter().zip(QualityFlag::CODES) {
assert_eq!(v.as_str(), *code);
assert_eq!(v.to_string(), *code);
assert_eq!(&v.to_string().parse::<QualityFlag>().unwrap(), v);
}
for (v, code) in MeasurementUnit::ALL.iter().zip(MeasurementUnit::CODES) {
assert_eq!(v.as_str(), *code);
assert_eq!(&v.to_string().parse::<MeasurementUnit>().unwrap(), v);
}
}
#[test]
fn codes_are_unique_within_each_type() {
for codes in [QualityFlag::CODES, Sparte::CODES, MeasurementUnit::CODES] {
let unique: std::collections::BTreeSet<_> = codes.iter().collect();
assert_eq!(
unique.len(),
codes.len(),
"codes must be distinct: {codes:?}"
);
}
}
#[test]
fn all_and_codes_stay_in_step() {
assert_eq!(Sparte::ALL.len(), Sparte::CODES.len());
assert_eq!(QualityFlag::ALL.len(), QualityFlag::CODES.len());
assert_eq!(MeasurementUnit::ALL.len(), MeasurementUnit::CODES.len());
assert_eq!(
crate::LoadProfile::ALL.len(),
crate::LoadProfile::CODES.len()
);
for (v, code) in crate::LoadProfile::ALL
.iter()
.zip(crate::LoadProfile::CODES)
{
assert_eq!(v.as_str(), *code, "{v:?}");
assert_eq!(&code.parse::<crate::LoadProfile>().unwrap(), v);
}
}
#[test]
fn parsing_is_case_and_whitespace_insensitive() {
assert_eq!(" strom ".parse::<Sparte>().unwrap(), Sparte::Strom);
assert_eq!(
"Measured".parse::<QualityFlag>().unwrap(),
QualityFlag::Measured
);
assert_eq!(
"kWh".parse::<MeasurementUnit>().unwrap(),
MeasurementUnit::KiloWattHour
);
}
#[test]
fn unrecognised_codes_are_errors_not_defaults() {
let err = "GARBAGE".parse::<QualityFlag>().unwrap_err();
assert_eq!(err.type_name(), "QualityFlag");
assert_eq!(err.input(), "GARBAGE");
assert_eq!(err.expected_values(), Some(QualityFlag::CODES));
assert!(err.to_string().contains("MEASURED"), "{err}");
assert!("".parse::<Sparte>().is_err());
assert!("KOHLE".parse::<Sparte>().is_err());
assert!("MWh".parse::<MeasurementUnit>().is_err());
}
#[test]
fn interval_obis_code_is_typed() {
use rust_decimal::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: dec!(2.5),
quality: QualityFlag::Measured,
obis_code: Some(ObisCode::STROM_BEZUG_HT),
};
assert_eq!(iv.tariff_register(), Some(1));
assert_eq!(iv.is_import_energy(), Some(true));
assert_eq!(iv.is_export_energy(), Some(false));
assert!("not an obis code".parse::<ObisCode>().is_err());
}
}