use rust_decimal::Decimal;
use time::Date;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::ids::{BdewCode, Eic, MaloId, MeloId};
use crate::interval::{Direction, Sparte};
use crate::obis::{ObisCode, RegisterUnit};
#[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 MarktRolle {
Nb,
Lf,
Msb,
Bkv,
#[cfg_attr(feature = "serde", serde(rename = "UENB"))]
Uenb,
Eiv,
#[cfg_attr(feature = "serde", serde(rename = "DV"))]
Direktvermarkter,
Mgv,
Esa,
}
impl MarktRolle {
pub const ALL: [Self; 9] = [
Self::Nb,
Self::Lf,
Self::Msb,
Self::Bkv,
Self::Uenb,
Self::Eiv,
Self::Direktvermarkter,
Self::Mgv,
Self::Esa,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Nb => "NB",
Self::Lf => "LF",
Self::Msb => "MSB",
Self::Bkv => "BKV",
Self::Uenb => "UENB",
Self::Eiv => "EIV",
Self::Direktvermarkter => "DV",
Self::Mgv => "MGV",
Self::Esa => "ESA",
}
}
#[must_use]
pub const fn abbreviation(self) -> &'static str {
match self {
Self::Nb => "NB",
Self::Lf => "LF",
Self::Msb => "MSB",
Self::Bkv => "BKV",
Self::Uenb => "ÜNB",
Self::Eiv => "EIV",
Self::Direktvermarkter => "DV",
Self::Mgv => "MGV",
Self::Esa => "ESA",
}
}
#[must_use]
pub fn is_mscons_receiver(self) -> bool {
matches!(
self,
Self::Lf | Self::Bkv | Self::Mgv | Self::Direktvermarkter | Self::Uenb
)
}
}
#[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 EnergyFlow {
Consumption,
Generation,
StorageCharge,
StorageDischarge,
Bidirectional,
}
impl EnergyFlow {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Consumption => "CONSUMPTION",
Self::Generation => "GENERATION",
Self::StorageCharge => "STORAGE_CHARGE",
Self::StorageDischarge => "STORAGE_DISCHARGE",
Self::Bidirectional => "BIDIRECTIONAL",
}
}
pub const ALL: [Self; 5] = [
Self::Consumption,
Self::Generation,
Self::StorageCharge,
Self::StorageDischarge,
Self::Bidirectional,
];
#[must_use]
pub const fn draws_from_grid(self) -> bool {
matches!(self, Self::Consumption | Self::StorageCharge)
}
#[must_use]
pub const fn feeds_grid(self) -> bool {
matches!(self, Self::Generation | Self::StorageDischarge)
}
#[must_use]
pub const fn is_storage(self) -> bool {
matches!(self, Self::StorageCharge | Self::StorageDischarge)
}
#[must_use]
pub const fn direction(self) -> Option<Direction> {
match self {
Self::Consumption | Self::StorageCharge => Some(Direction::Import),
Self::Generation | Self::StorageDischarge => Some(Direction::Export),
Self::Bidirectional => None,
}
}
}
crate::codes::string_codes! {
MarktRolle, aliases = [("ÜNB", Self::Uenb)];
EnergyFlow;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeasurementPoint {
pub malo_id: MaloId,
pub melo_id: Option<MeloId>,
pub meter_serial: Option<String>,
pub obis_code: ObisCode,
pub sparte: Sparte,
pub energy_flow: EnergyFlow,
pub accountable_role: MarktRolle,
pub accountable_mp_id: BdewCode,
pub bilanzkreis: Option<Eic>,
pub bilanzierungsgebiet: Option<Eic>,
pub is_virtual: bool,
pub wandler_factor: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date"))]
pub valid_from: Date,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::iso_date_option"))]
pub valid_to: Option<Date>,
}
impl MeasurementPoint {
#[must_use]
pub fn is_active(&self, on_date: Date) -> bool {
on_date >= self.valid_from && self.valid_to.is_none_or(|end| on_date <= end)
}
#[must_use]
pub fn direction(&self) -> Option<Direction> {
self.obis_code
.direction()
.or_else(|| self.energy_flow.direction())
}
#[must_use]
pub fn direction_conflict(&self) -> Option<(Direction, Direction)> {
let measured = self.obis_code.direction()?;
let declared = self.energy_flow.direction()?;
(measured != declared).then_some((measured, declared))
}
#[must_use]
pub fn is_reactive(&self) -> bool {
self.obis_code.is_reactive()
}
#[must_use]
pub fn is_gas(&self) -> bool {
matches!(self.sparte, Sparte::Gas)
}
#[must_use]
pub fn unit(&self) -> Option<RegisterUnit> {
self.obis_code.register_unit()
}
#[must_use]
pub fn tariff_register(&self) -> Option<u8> {
self.obis_code.tariff_register()
}
#[must_use]
pub fn apply_wandler(&self, raw_display_value: Decimal) -> Decimal {
raw_display_value * self.wandler_factor
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::macros::date;
fn bezug_point() -> MeasurementPoint {
MeasurementPoint {
malo_id: "51238696781".parse().unwrap(),
melo_id: Some("DE00056266802AO6G56M11SN51G21M24S".parse().unwrap()),
meter_serial: Some("MSN-001".to_owned()),
obis_code: ObisCode::STROM_BEZUG_TOTAL,
sparte: Sparte::Strom,
energy_flow: EnergyFlow::Consumption,
accountable_role: MarktRolle::Lf,
accountable_mp_id: "9900987654321".parse().unwrap(),
bilanzkreis: None,
bilanzierungsgebiet: None,
is_virtual: false,
wandler_factor: Decimal::ONE,
valid_from: date!(2026 - 01 - 01),
valid_to: None,
}
}
#[test]
fn active_within_validity() {
let mp = bezug_point();
assert!(mp.is_active(date!(2026 - 06 - 15)));
}
#[test]
fn inactive_before_valid_from() {
let mp = bezug_point();
assert!(!mp.is_active(date!(2025 - 12 - 31)));
}
#[test]
fn inactive_after_valid_to() {
let mut mp = bezug_point();
mp.valid_to = Some(date!(2026 - 06 - 30));
assert!(!mp.is_active(date!(2026 - 07 - 01)));
assert!(mp.is_active(date!(2026 - 06 - 30)));
}
#[test]
fn bezug_detection() {
let mp = bezug_point();
assert_eq!(mp.direction(), Some(Direction::Import));
}
#[test]
fn einspeisung_detection() {
let mut mp = bezug_point();
mp.obis_code = ObisCode::STROM_EINSPEISUNG_TOTAL;
mp.energy_flow = EnergyFlow::Generation;
assert_eq!(mp.direction(), Some(Direction::Export));
}
#[test]
fn gas_detection() {
let mut mp = bezug_point();
mp.sparte = Sparte::Gas;
mp.obis_code = ObisCode::GAS_VOLUME_M3;
assert!(mp.is_gas());
}
#[test]
fn virtual_meter_flag() {
let mut mp = bezug_point();
mp.is_virtual = true;
assert!(mp.is_virtual);
}
#[test]
fn the_unit_is_derived_from_the_obis_code() {
let mut mp = bezug_point();
assert_eq!(mp.unit(), Some(RegisterUnit::KiloWattHour));
mp.obis_code = ObisCode::STROM_BLINDARBEIT_Q1;
assert_eq!(mp.unit(), Some(RegisterUnit::KiloVarHour));
mp.obis_code = ObisCode::STROM_BEZUG_MAXIMUM;
assert_eq!(mp.unit(), Some(RegisterUnit::KiloWatt));
assert!(!mp.unit().unwrap().is_cumulative(), "a maximum is a power");
mp.obis_code = ObisCode::GAS_VOLUME_M3;
assert_eq!(mp.unit(), Some(RegisterUnit::CubicMetre));
mp.obis_code = ObisCode::WAERME_ENERGY;
assert_eq!(mp.unit(), Some(RegisterUnit::KiloWattHourThermal));
}
#[test]
fn the_merged_type_carries_the_register_facts() {
let mut mp = bezug_point();
mp.wandler_factor = dec!(100);
assert_eq!(mp.apply_wandler(dec!(1234)), dec!(123400));
assert_eq!(mp.tariff_register(), None, "1-0:1.8.0 is the total");
mp.obis_code = ObisCode::STROM_BEZUG_HT;
assert_eq!(mp.tariff_register(), Some(1));
mp.obis_code = ObisCode::STROM_BEZUG_NT;
assert_eq!(mp.tariff_register(), Some(2));
}
#[test]
fn the_metered_code_decides_the_direction_and_a_conflict_is_reported() {
let mut mp = bezug_point();
mp.energy_flow = EnergyFlow::Generation;
mp.obis_code = ObisCode::STROM_BEZUG_TOTAL;
assert_eq!(mp.direction(), Some(Direction::Import), "the code wins");
assert_eq!(
mp.direction_conflict(),
Some((Direction::Import, Direction::Export)),
);
mp.obis_code = ObisCode::GAS_VOLUME_M3;
assert_eq!(mp.direction(), Some(Direction::Export));
assert_eq!(mp.direction_conflict(), None);
mp.energy_flow = EnergyFlow::Bidirectional;
assert_eq!(mp.direction(), None);
mp.energy_flow = EnergyFlow::StorageCharge;
assert_eq!(mp.direction(), Some(Direction::Import));
mp.energy_flow = EnergyFlow::StorageDischarge;
assert_eq!(mp.direction(), Some(Direction::Export));
for flow in EnergyFlow::ALL {
mp.energy_flow = flow;
for code in [ObisCode::STROM_BEZUG_TOTAL, ObisCode::GAS_VOLUME_M3] {
mp.obis_code = code;
assert!(mp.direction().is_some() || flow == EnergyFlow::Bidirectional);
}
}
}
#[test]
fn energy_flow_groups_storage_with_the_direction_it_acts_in() {
assert!(EnergyFlow::Consumption.draws_from_grid());
assert!(EnergyFlow::StorageCharge.draws_from_grid());
assert!(EnergyFlow::Generation.feeds_grid());
assert!(EnergyFlow::StorageDischarge.feeds_grid());
assert!(!EnergyFlow::Bidirectional.draws_from_grid());
assert!(!EnergyFlow::Bidirectional.feeds_grid());
assert!(EnergyFlow::StorageCharge.is_storage());
assert!(!EnergyFlow::Consumption.is_storage());
}
#[test]
fn markt_rolle_mscons_receiver() {
assert!(MarktRolle::Lf.is_mscons_receiver());
assert!(MarktRolle::Bkv.is_mscons_receiver());
assert!(MarktRolle::Uenb.is_mscons_receiver());
assert!(!MarktRolle::Nb.is_mscons_receiver());
assert!(!MarktRolle::Msb.is_mscons_receiver());
}
}