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::ids::obis::ObisCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Sparte {
Strom,
Gas,
Waerme,
Wasser,
}
impl Sparte {
#[must_use]
pub const fn measured_unit(self) -> Unit {
match self {
Self::Strom | Self::Waerme => Unit::KiloWattHour,
Self::Gas | Self::Wasser => Unit::CubicMetre,
}
}
#[must_use]
pub const fn billing_unit(self) -> Unit {
match self {
Self::Strom | Self::Gas | Self::Waerme => Unit::KiloWattHour,
Self::Wasser => Unit::CubicMetre,
}
}
#[must_use]
pub const fn requires_conversion(self) -> bool {
matches!(
(self.measured_unit(), self.billing_unit()),
(Unit::KiloWattHour, Unit::CubicMetre) | (Unit::CubicMetre, Unit::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::ids::codes::string_codes! {
Sparte, aliases = [("WÄRME", Self::Waerme)];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Unit {
KiloWattHour,
KiloVarHour,
KiloWatt,
KiloVar,
CubicMetre,
KiloWattHourPerCubicMetre,
Dimensionless,
}
impl Unit {
pub const ALL: [Self; 7] = [
Self::KiloWattHour,
Self::KiloVarHour,
Self::KiloWatt,
Self::KiloVar,
Self::CubicMetre,
Self::KiloWattHourPerCubicMetre,
Self::Dimensionless,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::KiloWattHour => "KWH",
Self::KiloVarHour => "KVARH",
Self::KiloWatt => "KW",
Self::KiloVar => "KVAR",
Self::CubicMetre => "M3",
Self::KiloWattHourPerCubicMetre => "KWH_PER_M3",
Self::Dimensionless => "ONE",
}
}
#[must_use]
pub const fn symbol(self) -> &'static str {
match self {
Self::KiloWattHour => "kWh",
Self::KiloVarHour => "kvarh",
Self::KiloWatt => "kW",
Self::KiloVar => "kvar",
Self::CubicMetre => "m³",
Self::KiloWattHourPerCubicMetre => "kWh/m³",
Self::Dimensionless => "1",
}
}
#[must_use]
pub const fn is_energy(self) -> bool {
matches!(self, Self::KiloWattHour | Self::KiloVarHour)
}
#[must_use]
pub const fn is_cumulative(self) -> bool {
matches!(
self,
Self::KiloWattHour | Self::KiloVarHour | Self::CubicMetre
)
}
#[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 Unit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl Unit {
pub const CODES: &'static [&'static str] =
&["KWH", "KVARH", "KW", "KVAR", "M3", "KWH_PER_M3", "ONE"];
}
impl FromStr for Unit {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let t = s.trim();
if let Some(u) = Self::ALL
.into_iter()
.find(|u| u.as_str().eq_ignore_ascii_case(t))
{
return Ok(u);
}
Self::parse_scaled(t)
.filter(|scale| scale.is_canonical())
.map(|scale| scale.unit)
.ok_or_else(|| ParseError::one_of("Unit", s, Self::CODES))
}
}
#[cfg(feature = "serde")]
impl Serialize for Unit {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for Unit {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
text.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnitScale {
unit: Unit,
num: i64,
den: i64,
}
impl UnitScale {
#[must_use]
pub const fn unit(self) -> Unit {
self.unit
}
#[must_use]
pub const fn factor(self) -> (i64, i64) {
(self.num, self.den)
}
#[must_use]
pub const fn is_canonical(self) -> bool {
self.num == self.den
}
#[must_use]
pub fn apply(self, value: Decimal) -> Option<Decimal> {
if self.is_canonical() {
return Some(value);
}
value
.checked_mul(Decimal::from(self.num))?
.checked_div(Decimal::from(self.den))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
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",
}
}
#[must_use]
pub const fn market_code(self) -> Option<&'static str> {
match self {
Self::Measured => Some("220"),
Self::Substituted => Some("67"),
Self::Estimated => Some("187"),
Self::Preliminary => Some("Z18"),
Self::Faulty => Some("20"),
Self::Calculated | Self::Corrected | Self::Unknown => None,
}
}
pub const ALL: [Self; 8] = [
Self::Measured,
Self::Estimated,
Self::Substituted,
Self::Calculated,
Self::Corrected,
Self::Preliminary,
Self::Faulty,
Self::Unknown,
];
}
crate::ids::codes::string_codes! {
QualityFlag;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Direction {
Import,
Export,
}
impl Direction {
pub const ALL: [Self; 2] = [Self::Import, Self::Export];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Import => "IMPORT",
Self::Export => "EXPORT",
}
}
#[must_use]
pub const fn bezeichnung(self) -> &'static str {
match self {
Self::Import => "Bezug",
Self::Export => "Einspeisung",
}
}
#[must_use]
pub const fn reversed(self) -> Self {
match self {
Self::Import => Self::Export,
Self::Export => Self::Import,
}
}
}
crate::ids::codes::string_codes! {
Direction, aliases = [("BEZUG", Self::Import), ("EINSPEISUNG", Self::Export)];
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "RawInterval"))]
pub struct MeterInterval {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
to: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
value: Decimal,
quality: QualityFlag,
#[cfg_attr(feature = "serde", serde(default))]
obis: Option<ObisCode>,
}
#[cfg(feature = "serde")]
#[derive(Deserialize)]
struct RawInterval {
#[serde(with = "crate::wire::rfc3339")]
from: OffsetDateTime,
#[serde(with = "crate::wire::rfc3339")]
to: OffsetDateTime,
#[serde(with = "crate::wire::decimal")]
value: Decimal,
quality: QualityFlag,
#[serde(default)]
obis: Option<ObisCode>,
}
#[cfg(feature = "serde")]
impl TryFrom<RawInterval> for MeterInterval {
type Error = IntervalError;
fn try_from(raw: RawInterval) -> Result<Self, Self::Error> {
let iv = Self::new(raw.from, raw.to, raw.value, raw.quality)?;
Ok(match raw.obis {
Some(code) => iv.with_obis(code),
None => iv,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum IntervalError {
#[error("interval [{from}, {to}) is empty or reversed")]
Empty {
from: OffsetDateTime,
to: OffsetDateTime,
},
#[error("interval starting {from} overflows the time range")]
Overflow {
from: OffsetDateTime,
},
}
impl MeterInterval {
pub fn new(
from: OffsetDateTime,
to: OffsetDateTime,
value: Decimal,
quality: QualityFlag,
) -> Result<Self, IntervalError> {
if from >= to {
return Err(IntervalError::Empty { from, to });
}
Ok(Self {
from,
to,
value,
quality,
obis: None,
})
}
pub(crate) fn build(
from: OffsetDateTime,
to: OffsetDateTime,
value: Decimal,
quality: QualityFlag,
obis: Option<ObisCode>,
) -> Result<Self, IntervalError> {
let mut iv = Self::new(from, to, value, quality)?;
iv.obis = obis;
Ok(iv)
}
pub fn quarter_hour(
from: OffsetDateTime,
value: Decimal,
quality: QualityFlag,
) -> Result<Self, IntervalError> {
let to = from
.checked_add(time::Duration::minutes(15))
.ok_or(IntervalError::Overflow { from })?;
Self::new(from, to, value, quality)
}
pub fn hour(
from: OffsetDateTime,
value: Decimal,
quality: QualityFlag,
) -> Result<Self, IntervalError> {
let to = from
.checked_add(time::Duration::hours(1))
.ok_or(IntervalError::Overflow { from })?;
Self::new(from, to, value, quality)
}
#[must_use]
pub const fn with_obis(mut self, code: ObisCode) -> Self {
self.obis = Some(code);
self
}
#[must_use]
pub const fn with_quality(mut self, quality: QualityFlag) -> Self {
self.quality = quality;
self
}
#[must_use]
pub const fn with_value(mut self, value: Decimal, quality: QualityFlag) -> Self {
self.value = value;
self.quality = quality;
self
}
#[must_use]
pub const fn from(&self) -> OffsetDateTime {
self.from
}
#[must_use]
pub const fn to(&self) -> OffsetDateTime {
self.to
}
#[must_use]
pub const fn value(&self) -> Decimal {
self.value
}
#[must_use]
pub const fn quality(&self) -> QualityFlag {
self.quality
}
#[must_use]
pub const fn obis(&self) -> Option<ObisCode> {
self.obis
}
#[must_use]
pub fn duration(&self) -> time::Duration {
self.to - self.from
}
#[must_use]
pub fn duration_secs(&self) -> i64 {
self.duration().whole_seconds()
}
#[must_use]
pub fn demand_kw(&self) -> Option<Decimal> {
if let Some(unit) = self.obis.and_then(|c| c.unit())
&& !unit.is_energy()
{
return None;
}
self.value
.checked_mul(Decimal::from(3600u32))?
.checked_div(Decimal::from(self.duration_secs()))
}
#[must_use]
pub fn direction(&self) -> Option<Direction> {
self.obis.and_then(ObisCode::direction)
}
#[must_use]
pub fn tariff(&self) -> Option<u8> {
self.obis?.tariff()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::datetime;
#[test]
fn construction_refuses_an_empty_or_reversed_span() {
let t = datetime!(2026-06-01 12:00 UTC);
assert_eq!(
MeterInterval::new(t, t, dec!(1), QualityFlag::Measured),
Err(IntervalError::Empty { from: t, to: t })
);
assert!(
MeterInterval::new(
t,
t - time::Duration::minutes(15),
dec!(1),
QualityFlag::Measured
)
.is_err()
);
assert!(
MeterInterval::quarter_hour(
OffsetDateTime::new_utc(time::Date::MAX, time::Time::from_hms(23, 59, 0).unwrap()),
dec!(1),
QualityFlag::Measured
)
.is_err()
);
}
#[test]
fn a_quarter_hour_is_fifteen_minutes_even_across_a_transition() {
let slot = MeterInterval::quarter_hour(
datetime!(2026-10-25 0:00 UTC),
dec!(1),
QualityFlag::Measured,
)
.unwrap();
assert_eq!(slot.duration_secs(), 900);
assert_eq!(slot.to(), datetime!(2026-10-25 0:15 UTC));
}
#[test]
fn demand_is_only_for_energy() {
let iv = MeterInterval::quarter_hour(
datetime!(2026-01-01 0:00 UTC),
dec!(2.5),
QualityFlag::Measured,
)
.unwrap();
assert_eq!(iv.demand_kw(), Some(dec!(10)));
assert_eq!(
iv.clone()
.with_obis(ObisCode::STROM_BEZUG_LASTGANG)
.demand_kw(),
Some(dec!(10))
);
assert_eq!(iv.with_obis(ObisCode::GAS_VOLUME_M3).demand_kw(), None);
}
#[test]
fn quality_flag_billable() {
assert!(QualityFlag::Measured.is_billable());
assert!(QualityFlag::Substituted.is_billable());
assert!(QualityFlag::Estimated.is_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: std::collections::BTreeSet<u8> =
QualityFlag::ALL.iter().map(|q| q.severity_rank()).collect();
assert_eq!(ranks.len(), QualityFlag::ALL.len());
for a in QualityFlag::ALL {
for b in QualityFlag::ALL {
assert_eq!(a.worse_of(b), b.worse_of(a), "{a} vs {b}");
}
}
assert_eq!(QualityFlag::worst_of([]), QualityFlag::Unknown);
}
#[test]
fn interval_obis_code_is_typed() {
let iv = MeterInterval::quarter_hour(
datetime!(2026-01-01 0:00 UTC),
dec!(2.5),
QualityFlag::Measured,
)
.unwrap()
.with_obis(ObisCode::STROM_BEZUG_HT);
assert_eq!(iv.tariff(), Some(1));
assert_eq!(iv.direction(), Some(Direction::Import));
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn measured_unit_is_what_the_register_counts() {
assert_eq!(Sparte::Gas.measured_unit(), Unit::CubicMetre);
assert_eq!(Sparte::Wasser.measured_unit(), Unit::CubicMetre);
assert_eq!(Sparte::Waerme.measured_unit(), Unit::KiloWattHour);
assert_eq!(Sparte::Strom.measured_unit(), Unit::KiloWattHour);
for sparte in Sparte::ALL {
assert_eq!(
sparte.measured_unit() != sparte.billing_unit(),
sparte.requires_conversion()
);
}
}
#[test]
fn unit_parse_accepts_symbols_and_codes() {
assert_eq!("m³".parse::<Unit>(), Ok(Unit::CubicMetre));
assert_eq!(" M3 ".parse::<Unit>(), Ok(Unit::CubicMetre));
assert_eq!("kWh_th".parse::<Unit>(), Ok(Unit::KiloWattHour));
assert_eq!("kvarh".parse::<Unit>(), Ok(Unit::KiloVarHour));
assert_eq!(
"KWH_PER_M3".parse::<Unit>(),
Ok(Unit::KiloWattHourPerCubicMetre)
);
assert!("MWh".parse::<Unit>().is_err());
assert!("furlong".parse::<Unit>().is_err());
for (u, code) in Unit::ALL.iter().zip(Unit::CODES) {
assert_eq!(u.as_str(), *code);
assert_eq!(code.parse::<Unit>(), Ok(*u));
}
}
#[test]
fn gigajoule_conversion_is_exact() {
let gj = Unit::parse_scaled("GJ").unwrap();
assert_eq!(gj.unit(), Unit::KiloWattHour);
assert_eq!(
gj.apply(Decimal::from_str_exact("3.6").unwrap()),
Some(Decimal::from(1000u32))
);
assert_eq!(gj.apply(Decimal::from(9u32)), Some(Decimal::from(2500u32)));
let mj = Unit::parse_scaled("3B").unwrap();
assert_eq!(mj.factor(), (5, 18));
let jou = Unit::parse_scaled("JOU").unwrap();
assert_eq!(jou.apply(Decimal::from(3_600_000u32)), Some(Decimal::ONE));
assert_eq!(Unit::parse_scaled("MWh").unwrap().apply(Decimal::MAX), None);
}
#[test]
fn codes_round_trip() {
for (v, code) in Sparte::ALL.iter().zip(Sparte::CODES) {
assert_eq!(&code.parse::<Sparte>().unwrap(), v);
}
for (v, code) in QualityFlag::ALL.iter().zip(QualityFlag::CODES) {
assert_eq!(&code.parse::<QualityFlag>().unwrap(), v);
}
let err = "GARBAGE".parse::<QualityFlag>().unwrap_err();
assert_eq!(err.expected_values(), Some(QualityFlag::CODES));
}
}