use rust_decimal::Decimal;
#[cfg(test)]
use rust_decimal_macros::dec;
#[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 MeteringMode {
Slp,
Rlm,
IMsys,
}
impl MeteringMode {
#[must_use]
pub fn has_quarter_hour_data(self) -> bool {
matches!(self, Self::Rlm | Self::IMsys)
}
#[must_use]
pub fn satisfies_fernsteuerbarkeit(self) -> bool {
self == Self::IMsys
}
}
#[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 MesslokationTyp {
Einspeisemessung,
Erzeugungsmessung,
Bezugsmessung,
TeilnehmerMessung,
Virtuell,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MeterPoint {
pub melo_id: String,
pub typ: MesslokationTyp,
pub obis_code: String,
pub zaehlernummer: Option<String>,
pub is_primary_billing_point: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MeterConfiguration {
pub mode: MeteringMode,
pub meter_points: Vec<MeterPoint>,
pub is_bidirectional: bool,
}
impl MeterConfiguration {
#[must_use]
pub fn simple_ueberschuss(einspeise_melo_id: String, mode: MeteringMode) -> Self {
Self {
mode,
meter_points: vec![MeterPoint {
melo_id: einspeise_melo_id,
typ: MesslokationTyp::Einspeisemessung,
obis_code: "1-0:2.8.0".to_owned(),
zaehlernummer: None,
is_primary_billing_point: true,
}],
is_bidirectional: true,
}
}
#[must_use]
pub fn volleinspeisung(
erzeugungs_melo_id: String,
bezugs_melo_id: String,
mode: MeteringMode,
) -> Self {
Self {
mode,
meter_points: vec![
MeterPoint {
melo_id: erzeugungs_melo_id,
typ: MesslokationTyp::Erzeugungsmessung,
obis_code: "1-0:2.8.0".to_owned(),
zaehlernummer: None,
is_primary_billing_point: true,
},
MeterPoint {
melo_id: bezugs_melo_id,
typ: MesslokationTyp::Bezugsmessung,
obis_code: "1-0:1.8.0".to_owned(),
zaehlernummer: None,
is_primary_billing_point: false,
},
],
is_bidirectional: false,
}
}
#[must_use]
pub fn primary_billing_point(&self) -> Option<&MeterPoint> {
self.meter_points
.iter()
.find(|p| p.is_primary_billing_point)
}
#[must_use]
pub fn teilnehmer_count(&self) -> usize {
self.meter_points
.iter()
.filter(|p| p.typ == MesslokationTyp::TeilnehmerMessung)
.count()
}
#[must_use]
pub fn is_gemeinschaftliche_gebaeudeversorgung(&self) -> bool {
self.teilnehmer_count() > 0
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EinspeisemengeInput {
pub einspeisemessung_kwh: Option<Decimal>,
pub erzeugungsmessung_kwh: Option<Decimal>,
pub bezugsmessung_kwh: Option<Decimal>,
pub teilnehmer_kwh: Vec<Decimal>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MeteringError {
#[error("Missing required measurement: {0}")]
MissingData(&'static str),
#[error("Negative Einspeisemenge derived from meter data: {0} kWh")]
NegativeResult(String),
}
pub fn compute_einspeisemenge(
input: &EinspeisemengeInput,
messkonzept: crate::model::Messkonzept,
) -> Result<Decimal, MeteringError> {
use crate::model::Messkonzept;
match messkonzept {
Messkonzept::Volleinspeisung => {
input
.erzeugungsmessung_kwh
.or(input.einspeisemessung_kwh)
.ok_or(MeteringError::MissingData(
"erzeugungsmessung_kwh or einspeisemessung_kwh required for Volleinspeisung",
))
}
Messkonzept::Ueberschusseinspeisung => {
if let Some(einspeisung) = input.einspeisemessung_kwh {
return Ok(einspeisung.max(Decimal::ZERO));
}
match (input.erzeugungsmessung_kwh, input.bezugsmessung_kwh) {
(Some(erzg), _) => {
Ok(erzg.max(Decimal::ZERO))
}
_ => Err(MeteringError::MissingData(
"einspeisemessung_kwh required for Ueberschusseinspeisung",
)),
}
}
Messkonzept::Direktlieferung => {
input
.erzeugungsmessung_kwh
.ok_or(MeteringError::MissingData(
"erzeugungsmessung_kwh required for Direktlieferung",
))
}
}
}
pub fn compute_tenant_allocation(
input: &EinspeisemengeInput,
) -> Result<(Vec<(usize, Decimal)>, Decimal), MeteringError> {
let erzeugung = input
.erzeugungsmessung_kwh
.ok_or(MeteringError::MissingData(
"erzeugungsmessung_kwh required for tenant allocation",
))?;
if input.teilnehmer_kwh.is_empty() {
return Ok((vec![], erzeugung.max(Decimal::ZERO)));
}
let total_verbrauch: Decimal = input.teilnehmer_kwh.iter().cloned().sum();
let (allocations, grid_feed_in) = if erzeugung >= total_verbrauch {
let allocs: Vec<(usize, Decimal)> = input
.teilnehmer_kwh
.iter()
.enumerate()
.map(|(i, &v)| (i, v.max(Decimal::ZERO)))
.collect();
let feed_in = (erzeugung - total_verbrauch).max(Decimal::ZERO);
(allocs, feed_in)
} else {
let allocs: Vec<(usize, Decimal)> = input
.teilnehmer_kwh
.iter()
.enumerate()
.map(|(i, &verbrauch)| {
let alloc = if total_verbrauch.is_zero() {
Decimal::ZERO
} else {
(erzeugung * verbrauch / total_verbrauch).round_dp(3)
};
(i, alloc)
})
.collect();
(allocs, Decimal::ZERO)
};
Ok((allocations, grid_feed_in))
}
#[must_use]
pub fn compute_eigenverbrauch(
erzeugung_kwh: Option<Decimal>,
einspeisung_kwh: Option<Decimal>,
) -> Option<Decimal> {
match (erzeugung_kwh, einspeisung_kwh) {
(Some(erzg), Some(einsp)) => Some((erzg - einsp).max(Decimal::ZERO)),
_ => None,
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Sect14aModul2Measurement {
pub eigenverbrauch_ht_kwh: Decimal,
pub eigenverbrauch_nt_kwh: Decimal,
pub steuerungsmassnahme_kwh: Option<Decimal>,
}
impl Sect14aModul2Measurement {
#[must_use]
pub fn total_kwh(&self) -> Decimal {
self.eigenverbrauch_ht_kwh + self.eigenverbrauch_nt_kwh
}
#[must_use]
pub fn ht_ratio(&self) -> Option<Decimal> {
let total = self.total_kwh();
if total.is_zero() {
None
} else {
Some((self.eigenverbrauch_ht_kwh / total).round_dp(4))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rlm_has_quarter_hour_data() {
assert!(MeteringMode::Rlm.has_quarter_hour_data());
assert!(MeteringMode::IMsys.has_quarter_hour_data());
assert!(!MeteringMode::Slp.has_quarter_hour_data());
}
#[test]
fn imsys_satisfies_fernsteuerbarkeit() {
assert!(MeteringMode::IMsys.satisfies_fernsteuerbarkeit());
assert!(!MeteringMode::Rlm.satisfies_fernsteuerbarkeit());
assert!(!MeteringMode::Slp.satisfies_fernsteuerbarkeit());
}
#[test]
fn simple_ueberschuss_config() {
let config = MeterConfiguration::simple_ueberschuss(
"DE0001234567890123456789012345678".to_string(),
MeteringMode::Slp,
);
assert!(config.is_bidirectional);
assert_eq!(config.meter_points.len(), 1);
assert!(config.primary_billing_point().is_some());
assert!(!config.is_gemeinschaftliche_gebaeudeversorgung());
}
#[test]
fn volleinspeisung_config_has_two_points() {
let config = MeterConfiguration::volleinspeisung(
"DE000ERZEUGUNGS0000000000000000001".to_string(),
"DE000BEZUGS000000000000000000000001".to_string(),
MeteringMode::Rlm,
);
assert_eq!(config.meter_points.len(), 2);
assert!(!config.is_bidirectional);
let primary = config.primary_billing_point().unwrap();
assert_eq!(primary.typ, MesslokationTyp::Erzeugungsmessung);
}
#[test]
fn compute_ueberschuss_from_einspeisung() {
use crate::model::Messkonzept;
let input = EinspeisemengeInput {
einspeisemessung_kwh: Some(dec!(200)),
erzeugungsmessung_kwh: Some(dec!(300)),
bezugsmessung_kwh: Some(dec!(50)),
teilnehmer_kwh: vec![],
};
let kwh = compute_einspeisemenge(&input, Messkonzept::Ueberschusseinspeisung).unwrap();
assert_eq!(kwh, dec!(200));
}
#[test]
fn compute_volleinspeisung_uses_erzeugungs_melo() {
use crate::model::Messkonzept;
let input = EinspeisemengeInput {
einspeisemessung_kwh: Some(dec!(200)),
erzeugungsmessung_kwh: Some(dec!(300)), bezugsmessung_kwh: None,
teilnehmer_kwh: vec![],
};
let kwh = compute_einspeisemenge(&input, Messkonzept::Volleinspeisung).unwrap();
assert_eq!(kwh, dec!(300)); }
#[test]
fn compute_volleinspeisung_falls_back_to_einspeisung() {
use crate::model::Messkonzept;
let input = EinspeisemengeInput {
einspeisemessung_kwh: Some(dec!(200)),
erzeugungsmessung_kwh: None, bezugsmessung_kwh: None,
teilnehmer_kwh: vec![],
};
let kwh = compute_einspeisemenge(&input, Messkonzept::Volleinspeisung).unwrap();
assert_eq!(kwh, dec!(200));
}
#[test]
fn compute_missing_data_returns_error() {
use crate::model::Messkonzept;
let input = EinspeisemengeInput {
einspeisemessung_kwh: None,
erzeugungsmessung_kwh: None,
bezugsmessung_kwh: None,
teilnehmer_kwh: vec![],
};
assert!(compute_einspeisemenge(&input, Messkonzept::Ueberschusseinspeisung).is_err());
assert!(compute_einspeisemenge(&input, Messkonzept::Volleinspeisung).is_err());
}
#[test]
fn tenant_allocation_proportional() {
let input = EinspeisemengeInput {
einspeisemessung_kwh: None,
erzeugungsmessung_kwh: Some(dec!(1000)),
bezugsmessung_kwh: None,
teilnehmer_kwh: vec![dec!(300), dec!(200)],
};
let (allocs, feed_in) = compute_tenant_allocation(&input).unwrap();
assert_eq!(allocs.len(), 2);
assert_eq!(allocs[0].1, dec!(300));
assert_eq!(allocs[1].1, dec!(200));
assert_eq!(feed_in, dec!(500));
}
#[test]
fn tenant_allocation_partial_consumption() {
let input = EinspeisemengeInput {
einspeisemessung_kwh: None,
erzeugungsmessung_kwh: Some(dec!(1000)),
bezugsmessung_kwh: None,
teilnehmer_kwh: vec![dec!(300), dec!(200)],
};
let (_, feed_in) = compute_tenant_allocation(&input).unwrap();
assert_eq!(feed_in, dec!(500));
}
#[test]
fn tenant_allocation_generation_shortage() {
let input = EinspeisemengeInput {
einspeisemessung_kwh: None,
erzeugungsmessung_kwh: Some(dec!(400)),
bezugsmessung_kwh: None,
teilnehmer_kwh: vec![dec!(300), dec!(200)],
};
let (allocs, feed_in) = compute_tenant_allocation(&input).unwrap();
assert_eq!(allocs[0].1, dec!(240));
assert_eq!(allocs[1].1, dec!(160));
assert_eq!(feed_in, dec!(0));
}
#[test]
fn eigenverbrauch_computed_correctly() {
let ev = compute_eigenverbrauch(Some(dec!(400)), Some(dec!(250)));
assert_eq!(ev, Some(dec!(150)));
}
#[test]
fn eigenverbrauch_clamps_to_zero() {
let ev = compute_eigenverbrauch(Some(dec!(100)), Some(dec!(150)));
assert_eq!(ev, Some(dec!(0)));
}
#[test]
fn sect14a_ht_ratio() {
let m = Sect14aModul2Measurement {
eigenverbrauch_ht_kwh: dec!(200),
eigenverbrauch_nt_kwh: dec!(300),
steuerungsmassnahme_kwh: Some(dec!(50)),
};
assert_eq!(m.total_kwh(), dec!(500));
let ratio = m.ht_ratio().unwrap();
assert_eq!(ratio, dec!(0.4000)); }
}