use std::fmt;
use std::str::FromStr;
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ObisCode {
pub a: u8,
pub b: u8,
pub c: u8,
pub d: u8,
pub e: u8,
pub f: u8,
}
impl ObisCode {
pub const STORAGE_UNUSED: u8 = 255;
pub const MAX_LEN: usize = 23;
pub const STROM_BEZUG_TOTAL: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BEZUG_HT: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 1,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BEZUG_NT: Self = Self {
a: 1,
b: 0,
c: 1,
d: 8,
e: 2,
f: Self::STORAGE_UNUSED,
};
pub const STROM_EINSPEISUNG_TOTAL: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_EINSPEISUNG_HT: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 1,
f: Self::STORAGE_UNUSED,
};
pub const STROM_EINSPEISUNG_NT: Self = Self {
a: 1,
b: 0,
c: 2,
d: 8,
e: 2,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BEZUG_LASTGANG: Self = Self {
a: 1,
b: 0,
c: 1,
d: 29,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_EINSPEISUNG_LASTGANG: Self = Self {
a: 1,
b: 0,
c: 2,
d: 29,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BEZUG_MAXIMUM: Self = Self {
a: 1,
b: 0,
c: 1,
d: 6,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BEZUG_VORSCHUB: Self = Self {
a: 1,
b: 0,
c: 1,
d: 9,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_POSITIV: Self = Self {
a: 1,
b: 0,
c: 3,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_NEGATIV: Self = Self {
a: 1,
b: 0,
c: 4,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_Q1: Self = Self {
a: 1,
b: 0,
c: 5,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_Q2: Self = Self {
a: 1,
b: 0,
c: 6,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_Q3: Self = Self {
a: 1,
b: 0,
c: 7,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const STROM_BLINDARBEIT_Q4: Self = Self {
a: 1,
b: 0,
c: 8,
d: 8,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const GAS_VOLUME_M3: Self = Self {
a: 7,
b: 0,
c: 3,
d: 0,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const GAS_NORMVOLUMEN_UMGEWERTET: Self = Self {
a: 7,
b: 0,
c: 13,
d: 2,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const GAS_ZUSTANDSZAHL: Self = Self {
a: 7,
b: 0,
c: 52,
d: 0,
e: 22,
f: Self::STORAGE_UNUSED,
};
pub const GAS_BRENNWERT_MONATSMITTEL: Self = Self {
a: 7,
b: 0,
c: 54,
d: 0,
e: 22,
f: Self::STORAGE_UNUSED,
};
pub const WAERME_ENERGY: Self = Self {
a: 6,
b: 0,
c: 1,
d: 0,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const KAELTE_ENERGY: Self = Self {
a: 5,
b: 0,
c: 1,
d: 0,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const WASSER_KALT_VOLUME: Self = Self {
a: 8,
b: 0,
c: 1,
d: 0,
e: 0,
f: Self::STORAGE_UNUSED,
};
pub const WASSER_WARM_VOLUME: Self = Self {
a: 9,
b: 0,
c: 1,
d: 0,
e: 0,
f: Self::STORAGE_UNUSED,
};
#[must_use]
pub fn is_electricity(&self) -> bool {
self.a == 1
}
#[must_use]
pub fn is_gas(&self) -> bool {
self.a == 7
}
#[must_use]
pub fn is_heat(&self) -> bool {
matches!(self.a, 5 | 6)
}
#[must_use]
pub fn is_water(&self) -> bool {
matches!(self.a, 8 | 9)
}
#[must_use]
pub fn is_heat_cost_allocator(&self) -> bool {
self.a == 4
}
#[must_use]
pub fn is_reactive(&self) -> bool {
self.a == 1 && matches!(self.c, 3..=8)
}
#[must_use]
pub fn is_lastgang(&self) -> bool {
self.d == 29
}
#[must_use]
pub fn is_maximum(&self) -> bool {
self.d == 6
}
#[must_use]
pub fn is_zaehlerstand(&self) -> bool {
self.d == 8
}
#[must_use]
pub fn is_vorschub(&self) -> bool {
self.d == 9
}
#[must_use]
pub fn is_import(&self) -> bool {
self.a == 1 && self.c == 1
}
#[must_use]
pub fn is_export(&self) -> bool {
self.a == 1 && self.c == 2
}
#[must_use]
pub fn is_einspeisung(&self) -> bool {
self.is_export()
}
pub const TARIFF_FEHLERREGISTER: u8 = 63;
#[must_use]
pub fn tariff_register(&self) -> Option<u8> {
match self.e {
0 | Self::TARIFF_FEHLERREGISTER => None,
n => Some(n),
}
}
#[must_use]
pub fn is_fehlerregister(&self) -> bool {
self.e == Self::TARIFF_FEHLERREGISTER
}
#[must_use]
pub fn is_total_register(&self) -> bool {
self.e == 0
}
#[must_use]
pub fn is_ht(&self) -> bool {
self.e == 1
}
#[must_use]
pub fn is_nt(&self) -> bool {
self.e == 2
}
#[must_use]
pub fn default_resolution(&self) -> Option<crate::resolution::IntervalResolution> {
use crate::resolution::IntervalResolution;
if self.is_fehlerregister() {
return None;
}
if self.a == 1 && self.is_lastgang() {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 1 && (self.c == 1 || self.c == 2) && self.d == 8 {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 1 && matches!(self.c, 3..=8) {
return Some(IntervalResolution::QuarterHour);
}
if self.a == 7 {
return Some(IntervalResolution::Hour);
}
if matches!(self.a, 5 | 6) {
return Some(IntervalResolution::Hour);
}
if matches!(self.a, 8 | 9) {
return Some(IntervalResolution::Day);
}
None
}
#[must_use]
pub fn register_unit(&self) -> Option<RegisterUnit> {
match self.a {
1 => {
let reactive = self.is_reactive();
let active = matches!(self.c, 1 | 2);
if !reactive && !active {
return None;
}
Some(match (reactive, self.is_maximum()) {
(false, false) => RegisterUnit::KiloWattHour,
(false, true) => RegisterUnit::KiloWatt,
(true, false) => RegisterUnit::KiloVarHour,
(true, true) => RegisterUnit::KiloVar,
})
}
5 | 6 => Some(RegisterUnit::KiloWattHourThermal),
7..=9 => Some(RegisterUnit::CubicMetre),
_ => None,
}
}
#[must_use]
pub const fn has_unused_storage(&self) -> bool {
self.f == Self::STORAGE_UNUSED
}
pub fn parse(s: &str) -> Result<Self, ParseError> {
s.parse()
}
#[must_use]
pub fn to_full_string(&self) -> String {
format!("{self:#}")
}
pub fn normalize(s: &str) -> Result<String, ParseError> {
Ok(s.parse::<Self>()?.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum RegisterUnit {
KiloWattHour,
KiloVarHour,
KiloWatt,
KiloVar,
CubicMetre,
KiloWattHourThermal,
}
impl RegisterUnit {
pub const ALL: [Self; 6] = [
Self::KiloWattHour,
Self::KiloVarHour,
Self::KiloWatt,
Self::KiloVar,
Self::CubicMetre,
Self::KiloWattHourThermal,
];
#[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::KiloWattHourThermal => "kWh_th",
}
}
#[must_use]
pub const fn is_cumulative(self) -> bool {
!matches!(self, Self::KiloWatt | Self::KiloVar)
}
}
struct CodeBuf {
buf: [u8; ObisCode::MAX_LEN],
len: usize,
}
impl CodeBuf {
const fn new() -> Self {
Self {
buf: [0; ObisCode::MAX_LEN],
len: 0,
}
}
fn as_str(&self) -> Result<&str, fmt::Error> {
std::str::from_utf8(&self.buf[..self.len]).map_err(|_| fmt::Error)
}
}
impl fmt::Write for CodeBuf {
fn write_str(&mut self, s: &str) -> fmt::Result {
let end = self.len.checked_add(s.len()).ok_or(fmt::Error)?;
let dst = self.buf.get_mut(self.len..end).ok_or(fmt::Error)?;
dst.copy_from_slice(s.as_bytes());
self.len = end;
Ok(())
}
}
impl fmt::Display for ObisCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use fmt::Write as _;
let mut buf = CodeBuf::new();
write!(
buf,
"{}-{}:{}.{}.{}",
self.a, self.b, self.c, self.d, self.e
)?;
if f.alternate() || !self.has_unused_storage() {
write!(buf, "*{}", self.f)?;
}
f.pad(buf.as_str()?)
}
}
impl From<ObisCode> for String {
fn from(o: ObisCode) -> String {
o.to_string()
}
}
const OBIS_FORMAT: &str = "A-B:C.D.E*F, e.g. 1-0:1.8.0 (the *F suffix is optional)";
impl FromStr for ObisCode {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse_obis(s).ok_or_else(|| ParseError::format("ObisCode", s, OBIS_FORMAT))
}
}
impl TryFrom<&str> for ObisCode {
type Error = ParseError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
fn parse_obis(s: &str) -> Option<ObisCode> {
let (a_str, rest) = s.trim().split_once('-')?;
let (b_str, cd_ef) = rest.split_once(':')?;
let (cde, f_str) = match cd_ef.split_once('*') {
Some((l, r)) => (l, Some(r)),
None => (cd_ef, None),
};
let (c_str, d_e) = cde.split_once('.')?;
let (d_str, e_str) = d_e.split_once('.')?;
Some(ObisCode {
a: group(a_str)?,
b: group(b_str)?,
c: group(c_str)?,
d: group(d_str)?,
e: group(e_str)?,
f: match f_str {
Some(t) => group(t)?,
None => ObisCode::STORAGE_UNUSED,
},
})
}
fn group(s: &str) -> Option<u8> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
#[cfg(feature = "serde")]
mod serde_impl {
use super::ObisCode;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
impl Serialize for ObisCode {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
struct ObisCodeVisitor;
impl Visitor<'_> for ObisCodeVisitor {
type Value = ObisCode;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an OBIS code string such as \"1-0:1.8.0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<ObisCode, E> {
v.parse().map_err(de::Error::custom)
}
}
impl<'de> Deserialize<'de> for ObisCode {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(ObisCodeVisitor)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_standard_strom_bezug() {
let code: ObisCode = "1-0:1.8.0*255".parse().unwrap();
assert_eq!(code, ObisCode::STROM_BEZUG_TOTAL);
assert!(code.is_electricity());
assert!(code.is_import(), "1-0:1.8.0 C=1,D=8 should be import");
assert!(code.is_total_register());
assert!(!code.is_export());
}
#[test]
fn parse_ht_register() {
let code: ObisCode = "1-0:1.8.1*255".parse().unwrap();
assert_eq!(code, ObisCode::STROM_BEZUG_HT);
assert!(code.is_ht());
assert!(!code.is_nt());
assert_eq!(code.tariff_register(), Some(1));
}
#[test]
fn parse_without_storage_number() {
let code: ObisCode = "1-0:2.8.0".parse().unwrap();
assert_eq!(code.f, 255);
assert_eq!(code.c, 2);
assert_eq!(code.d, 8);
assert!(
code.is_export(),
"1-0:2.8.0 should be Einspeisung/export (C=2, D=8)"
);
assert!(code.is_einspeisung(), "should alias is_export()");
assert_eq!(code, ObisCode::STROM_EINSPEISUNG_TOTAL);
}
#[test]
fn parse_gas_volume() {
let code: ObisCode = "7-0:3.0.0*255".parse().unwrap();
assert_eq!(code, ObisCode::GAS_VOLUME_M3);
assert!(code.is_gas());
assert!(!code.is_electricity());
}
#[test]
fn invalid_code_returns_error() {
assert!("not-an-obis".parse::<ObisCode>().is_err());
assert!("1-0:1.8".parse::<ObisCode>().is_err());
assert!("".parse::<ObisCode>().is_err());
}
#[test]
fn constants_are_correct() {
assert_eq!(ObisCode::STROM_BEZUG_TOTAL.to_string(), "1-0:1.8.0");
assert_eq!(ObisCode::STROM_BEZUG_HT.to_string(), "1-0:1.8.1");
assert_eq!(ObisCode::STROM_BEZUG_NT.to_string(), "1-0:1.8.2");
assert_eq!(ObisCode::STROM_EINSPEISUNG_TOTAL.to_string(), "1-0:2.8.0");
assert_eq!(ObisCode::GAS_VOLUME_M3.to_string(), "7-0:3.0.0");
}
}
#[cfg(test)]
mod mako_semantics_tests {
use super::*;
#[test]
fn direction_is_group_c_across_every_messart() {
for (code, what) in [
("1-0:1.8.0", "Zählerstand"),
("1-0:1.9.0", "Vorschub"),
("1-0:1.29.0", "Lastgang"),
("1-0:1.6.0", "Maximum"),
("1-0:1.8.1", "Zählerstand HT"),
] {
let c: ObisCode = code.parse().unwrap();
assert!(c.is_import(), "{code} ({what}) is Bezug");
assert!(!c.is_export(), "{code} ({what}) is not Lieferung");
}
for (code, what) in [
("1-0:2.8.0", "Zählerstand"),
("1-0:2.9.0", "Vorschub"),
("1-0:2.29.0", "Lastgang"),
("1-0:2.6.0", "Maximum"),
] {
let c: ObisCode = code.parse().unwrap();
assert!(c.is_export(), "{code} ({what}) is Lieferung");
assert!(c.is_einspeisung(), "{code} ({what}) aliases is_export");
assert!(!c.is_import(), "{code} ({what}) is not Bezug");
}
}
#[test]
fn messart_is_not_a_direction() {
let vorschub: ObisCode = "1-0:1.9.0".parse().unwrap();
assert!(vorschub.is_vorschub() && vorschub.is_import());
let blind_vorschub: ObisCode = "1-0:3.9.0".parse().unwrap();
assert!(blind_vorschub.is_vorschub());
assert!(blind_vorschub.is_reactive());
assert!(
!blind_vorschub.is_export(),
"D = 9 is Vorschub (Zeitintegral 2), not a reverse-direction flag"
);
}
#[test]
fn lastgang_and_maximum_are_different_registers() {
let lastgang = ObisCode::STROM_BEZUG_LASTGANG;
let maximum = ObisCode::STROM_BEZUG_MAXIMUM;
assert_eq!(lastgang.to_string(), "1-0:1.29.0");
assert_eq!(maximum.to_string(), "1-0:1.6.0");
assert!(lastgang.is_lastgang() && !lastgang.is_maximum());
assert!(maximum.is_maximum() && !maximum.is_lastgang());
assert_ne!(lastgang, maximum);
use crate::resolution::IntervalResolution;
assert_eq!(
lastgang.default_resolution(),
Some(IntervalResolution::QuarterHour)
);
assert_eq!(maximum.default_resolution(), None);
}
#[test]
fn every_reactive_quadrant_is_reactive() {
for c in 3..=8u8 {
let code: ObisCode = format!("1-0:{c}.8.0").parse().unwrap();
assert!(code.is_reactive(), "1-0:{c}.8.0 is Blindarbeit");
assert!(!code.is_import(), "Blindarbeit is not Wirkarbeit Bezug");
}
for named in [
ObisCode::STROM_BLINDARBEIT_POSITIV,
ObisCode::STROM_BLINDARBEIT_NEGATIV,
ObisCode::STROM_BLINDARBEIT_Q1,
ObisCode::STROM_BLINDARBEIT_Q2,
ObisCode::STROM_BLINDARBEIT_Q3,
ObisCode::STROM_BLINDARBEIT_Q4,
] {
assert!(named.is_reactive(), "{named}");
}
assert!(!ObisCode::STROM_BEZUG_TOTAL.is_reactive());
assert!(!ObisCode::STROM_EINSPEISUNG_TOTAL.is_reactive());
}
#[test]
fn the_c_group_is_scoped_to_the_medium() {
assert!(
!ObisCode::GAS_VOLUME_M3.is_reactive(),
"7-0:3.0.0 is a gas volume; C = 3 only means Blindleistung for A = 1"
);
assert!(ObisCode::GAS_VOLUME_M3.is_gas());
assert!(!ObisCode::GAS_VOLUME_M3.is_import());
assert!(!ObisCode::WAERME_ENERGY.is_import());
}
#[test]
fn fehlerregister_is_not_a_tariff() {
let fehler: ObisCode = "1-0:1.8.63".parse().unwrap();
assert!(fehler.is_fehlerregister());
assert_eq!(fehler.tariff_register(), None);
assert!(
!fehler.is_total_register(),
"E = 63 is not the total either"
);
assert_eq!(
fehler.default_resolution(),
None,
"a fault counter is not a time series"
);
assert_eq!(ObisCode::STROM_BEZUG_HT.tariff_register(), Some(1));
assert_eq!(ObisCode::STROM_BEZUG_NT.tariff_register(), Some(2));
let t62: ObisCode = "1-0:1.8.62".parse().unwrap();
assert_eq!(t62.tariff_register(), Some(62));
assert!(!t62.is_fehlerregister());
assert_eq!(ObisCode::STROM_BEZUG_TOTAL.tariff_register(), None);
}
}
#[cfg(test)]
mod canonical_string_tests {
use super::*;
const CANONICAL: &[&str] = &[
"1-0:1.8.0",
"1-0:1.8.1",
"1-0:1.8.2",
"1-0:2.8.0",
"1-0:2.8.1",
"1-0:2.8.2",
"1-0:1.29.0",
"1-0:3.8.0",
"1-0:4.8.0",
"1-0:12.7.0",
"7-0:3.0.0",
"6-0:1.0.0",
"5-0:1.0.0",
"8-0:1.0.0",
"9-0:1.0.0",
"4-0:1.0.0",
"1-0:1.8.0*1",
"1-0:1.8.0*0",
];
#[test]
fn canonical_strings_survive_a_round_trip() {
for s in CANONICAL {
let code: ObisCode = s.parse().unwrap_or_else(|e| panic!("{s}: {e}"));
assert_eq!(&code.to_string(), s, "{s} is not string-stable");
}
}
#[test]
fn both_spellings_are_one_value_and_one_string() {
let typed: ObisCode = "1-0:1.8.0".parse().unwrap();
let emitted: ObisCode = "1-0:1.8.0*255".parse().unwrap();
assert_eq!(typed, emitted, "same channel, same value");
assert_eq!(
typed.to_string(),
emitted.to_string(),
"same channel, same key"
);
assert_eq!(typed.to_string(), "1-0:1.8.0");
}
#[test]
fn lenient_spellings_normalise_onto_the_canonical_one() {
for s in [
"1-0:1.8.0",
"1-0:1.8.0*255",
" 1-0:1.8.0 ",
"01-00:01.08.00",
"1-0:01.8.0*0255",
] {
assert_eq!(
ObisCode::normalize(s).unwrap_or_else(|e| panic!("{s}: {e}")),
"1-0:1.8.0",
"{s} must normalise onto the canonical spelling"
);
}
}
#[test]
fn normalize_is_idempotent() {
for s in CANONICAL {
let once = ObisCode::normalize(s).unwrap();
assert_eq!(ObisCode::normalize(&once).unwrap(), once, "{s}");
}
}
#[test]
fn full_form_is_available_and_parses_back() {
let code = ObisCode::STROM_BEZUG_TOTAL;
assert_eq!(code.to_full_string(), "1-0:1.8.0*255");
assert_eq!(format!("{code:#}"), "1-0:1.8.0*255");
assert_eq!(code.to_full_string().parse::<ObisCode>().unwrap(), code);
}
#[test]
fn a_real_storage_group_is_never_elided() {
let historical: ObisCode = "1-0:1.8.0*1".parse().unwrap();
assert!(!historical.has_unused_storage());
assert_eq!(historical.to_string(), "1-0:1.8.0*1");
assert_ne!(historical, ObisCode::STROM_BEZUG_TOTAL);
assert_ne!(
historical.to_string(),
ObisCode::STROM_BEZUG_TOTAL.to_string()
);
}
#[test]
fn signed_and_empty_groups_are_rejected() {
for s in [
"+1-0:1.8.0",
"1-0:1.8.0*+255",
"1--0:1.8.0",
"1-0:1.8.",
"1-0:.8.0",
"1-0:1.8.0*",
"1-0:1.8.0.5",
"1 - 0 : 1.8.0",
"1-0:1.8.0*256",
"256-0:1.8.0",
] {
assert!(s.parse::<ObisCode>().is_err(), "{s:?} must not parse");
}
}
#[test]
fn ordering_is_by_value_group() {
let mut codes = [
ObisCode::GAS_VOLUME_M3,
ObisCode::STROM_BEZUG_NT,
ObisCode::STROM_BEZUG_TOTAL,
ObisCode::STROM_BEZUG_HT,
];
codes.sort();
assert_eq!(
codes,
[
ObisCode::STROM_BEZUG_TOTAL,
ObisCode::STROM_BEZUG_HT,
ObisCode::STROM_BEZUG_NT,
ObisCode::GAS_VOLUME_M3,
]
);
}
}
#[cfg(test)]
mod media_group_tests {
use super::*;
#[test]
fn medium_group_a_follows_the_dlms_media_list() {
let hca: ObisCode = "4-0:1.0.0".parse().unwrap();
let cooling: ObisCode = "5-0:1.0.0".parse().unwrap();
let heat: ObisCode = "6-0:1.0.0".parse().unwrap();
let gas: ObisCode = "7-0:3.0.0".parse().unwrap();
let cold_water: ObisCode = "8-0:1.0.0".parse().unwrap();
let hot_water: ObisCode = "9-0:1.0.0".parse().unwrap();
assert!(hca.is_heat_cost_allocator());
assert!(
!hca.is_heat(),
"an HCA measures Verbrauchseinheiten, not kWh"
);
assert!(cooling.is_heat() && heat.is_heat());
assert!(gas.is_gas() && !gas.is_heat());
assert!(cold_water.is_water() && hot_water.is_water());
assert!(!cold_water.is_heat(), "A=8 is cold water");
}
#[test]
fn named_constants_match_their_medium_predicate() {
assert!(ObisCode::WAERME_ENERGY.is_heat(), "heat is medium 6");
assert!(!ObisCode::WAERME_ENERGY.is_water());
assert_eq!(ObisCode::WAERME_ENERGY.to_string(), "6-0:1.0.0");
assert!(ObisCode::KAELTE_ENERGY.is_heat(), "cooling is medium 5");
assert!(ObisCode::WASSER_KALT_VOLUME.is_water());
assert!(!ObisCode::WASSER_KALT_VOLUME.is_heat());
assert!(ObisCode::WASSER_WARM_VOLUME.is_water());
assert!(ObisCode::GAS_VOLUME_M3.is_gas());
assert!(ObisCode::STROM_BEZUG_TOTAL.is_electricity());
use crate::resolution::IntervalResolution;
assert_eq!(
ObisCode::WAERME_ENERGY.default_resolution(),
Some(IntervalResolution::Hour),
"a heat meter is read hourly, not daily like a water submeter"
);
assert_eq!(
ObisCode::WASSER_KALT_VOLUME.default_resolution(),
Some(IntervalResolution::Day)
);
}
#[test]
fn water_defaults_to_daily_resolution() {
use crate::resolution::IntervalResolution;
let cold_water: ObisCode = "8-0:1.0.0".parse().unwrap();
let heat: ObisCode = "6-0:1.0.0".parse().unwrap();
assert_eq!(
cold_water.default_resolution(),
Some(IntervalResolution::Day)
);
assert_eq!(heat.default_resolution(), Some(IntervalResolution::Hour));
}
}