use time::OffsetDateTime;
use crate::classification::Messtyp;
use crate::resolution::IntervalResolution;
#[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 EligibilityBasis {
Zaehlerstandsgangmessung,
RegistrierendeLeistungsmessung,
}
impl EligibilityBasis {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Zaehlerstandsgangmessung => "ZAEHLERSTANDSGANGMESSUNG",
Self::RegistrierendeLeistungsmessung => "REGISTRIERENDE_LEISTUNGSMESSUNG",
}
}
#[must_use]
pub const fn legal_basis(self) -> &'static str {
match self {
Self::Zaehlerstandsgangmessung => "§2 Satz 1 Nr. 27 MsbG",
Self::RegistrierendeLeistungsmessung => "§42c Abs. 1 EnWG",
}
}
}
#[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"))]
#[non_exhaustive]
pub enum Finding {
NotRemotelyReadable,
ModerneMesseinrichtungWithoutGateway,
BalancingMethodHasNoQuarterHourValues,
MeterTypeQualifiesForNeitherLimb,
ZaehlertypMissing,
BilanzierungsmethodeMissing,
NoReadings,
NotQuarterHourResolution,
ResolutionUndeterminable,
CoverageBelowThreshold,
}
impl Finding {
pub const ALL: [Self; 10] = [
Self::NotRemotelyReadable,
Self::ModerneMesseinrichtungWithoutGateway,
Self::BalancingMethodHasNoQuarterHourValues,
Self::MeterTypeQualifiesForNeitherLimb,
Self::ZaehlertypMissing,
Self::BilanzierungsmethodeMissing,
Self::NoReadings,
Self::NotQuarterHourResolution,
Self::ResolutionUndeterminable,
Self::CoverageBelowThreshold,
];
#[must_use]
pub const fn description_de(self) -> &'static str {
match self {
Self::NotRemotelyReadable => {
"Zähler ist als nicht fernauslesbar gekennzeichnet — keine \
Zählerstandsgangmessung möglich"
}
Self::ModerneMesseinrichtungWithoutGateway => {
"moderne Messeinrichtung ohne Smart-Meter-Gateway \
(§ 2 Satz 1 Nr. 15 MsbG) — iMSys-Rollout oder RLM erforderlich"
}
Self::BalancingMethodHasNoQuarterHourValues => {
"Bilanzierungsmethode liefert keine Viertelstundenwerte"
}
Self::MeterTypeQualifiesForNeitherLimb => "Zählertyp ist weder iMSys noch RLM",
Self::ZaehlertypMissing => "kein Zählertyp im Stammdatensatz hinterlegt",
Self::BilanzierungsmethodeMissing => "keine Bilanzierungsmethode an der Marktlokation",
Self::NoReadings => "keine Messwerte im Betrachtungszeitraum",
Self::NotQuarterHourResolution => "Messwerte liegen nicht viertelstündlich vor",
Self::ResolutionUndeterminable => "Intervalllänge konnte nicht bestimmt werden",
Self::CoverageBelowThreshold => "Abdeckung unter der Schwelle",
}
}
}
#[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 Zaehlertyp {
IntelligentesMesssystem,
ModerneMesseinrichtung,
Conventional,
}
impl Zaehlertyp {
pub const ALL: [Self; 3] = [
Self::IntelligentesMesssystem,
Self::ModerneMesseinrichtung,
Self::Conventional,
];
}
#[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 Bilanzierungsmethode {
Rlm,
Slp,
Ims,
Tlp,
Pauschal,
}
impl Bilanzierungsmethode {
pub const ALL: [Self; 5] = [Self::Rlm, Self::Slp, Self::Ims, Self::Tlp, Self::Pauschal];
#[must_use]
pub const fn precludes_quarter_hour_values(self) -> bool {
matches!(self, Self::Slp | Self::Tlp | Self::Pauschal)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MeteringCapabilityInput {
pub zaehlertyp: Option<Zaehlertyp>,
pub ist_fernauslesbar: Option<bool>,
pub bilanzierungsmethode: Option<Bilanzierungsmethode>,
pub smgw_operational: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Capability {
Qualified(EligibilityBasis),
Disqualified,
Unknown,
}
impl Capability {
#[must_use]
pub const fn basis(self) -> Option<EligibilityBasis> {
match self {
Self::Qualified(b) => Some(b),
_ => None,
}
}
}
#[must_use]
pub fn assess_capability(input: &MeteringCapabilityInput) -> (Capability, Vec<Finding>) {
let mut findings = Vec::new();
let methode = input.bilanzierungsmethode;
let typ = input.zaehlertyp;
if methode == Some(Bilanzierungsmethode::Rlm) {
return (
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung),
findings,
);
}
let looks_imsys = methode == Some(Bilanzierungsmethode::Ims)
|| typ == Some(Zaehlertyp::IntelligentesMesssystem)
|| input.smgw_operational == Some(true);
if looks_imsys {
if input.ist_fernauslesbar == Some(false) {
findings.push(Finding::NotRemotelyReadable);
return (Capability::Disqualified, findings);
}
return (
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung),
findings,
);
}
if typ == Some(Zaehlertyp::ModerneMesseinrichtung) {
findings.push(Finding::ModerneMesseinrichtungWithoutGateway);
return (Capability::Disqualified, findings);
}
if methode.is_some_and(Bilanzierungsmethode::precludes_quarter_hour_values) {
findings.push(Finding::BalancingMethodHasNoQuarterHourValues);
return (Capability::Disqualified, findings);
}
if typ.is_none() {
findings.push(Finding::ZaehlertypMissing);
}
if methode.is_none() {
findings.push(Finding::BilanzierungsmethodeMissing);
}
if findings.is_empty() {
findings.push(Finding::MeterTypeQualifiesForNeitherLimb);
return (Capability::Disqualified, findings);
}
(Capability::Unknown, findings)
}
#[derive(Debug, Clone, Default)]
pub struct DeliveryEvidenceInput {
pub resolution: Option<IntervalResolution>,
pub messtyp: Option<Messtyp>,
pub coverage_pct: Option<f64>,
pub reading_count: u64,
pub last_reading_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum Delivery {
Delivering,
Insufficient,
Absent,
}
pub const DEFAULT_COVERAGE_THRESHOLD_PCT: f64 = 95.0;
#[must_use]
pub fn assess_delivery(
input: &DeliveryEvidenceInput,
coverage_threshold_pct: f64,
) -> (Delivery, Vec<Finding>) {
if input.reading_count == 0 {
return (Delivery::Absent, vec![Finding::NoReadings]);
}
if input.resolution != Some(IntervalResolution::QuarterHour) {
let finding = if input.resolution.is_some() {
Finding::NotQuarterHourResolution
} else {
Finding::ResolutionUndeterminable
};
return (Delivery::Insufficient, vec![finding]);
}
if input
.coverage_pct
.is_some_and(|cov| cov < coverage_threshold_pct)
{
return (
Delivery::Insufficient,
vec![Finding::CoverageBelowThreshold],
);
}
(Delivery::Delivering, Vec::new())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum SharingReadiness {
Ready,
CapableNotDelivering,
NotCapable,
Unknown,
}
impl SharingReadiness {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Ready => "READY",
Self::CapableNotDelivering => "CAPABLE_NOT_DELIVERING",
Self::NotCapable => "NOT_CAPABLE",
Self::Unknown => "UNKNOWN",
}
}
#[must_use]
pub const fn required_action(self) -> &'static str {
match self {
Self::Ready => "keine",
Self::CapableNotDelivering => "Zählerstandsgangmessung beauftragen",
Self::NotCapable => "iMSys-Rollout oder RLM-Umbau beauftragen",
Self::Unknown => "Stammdaten vervollständigen",
}
}
}
#[must_use]
pub const fn combine(capability: Capability, delivery: Delivery) -> SharingReadiness {
match (capability, delivery) {
(Capability::Qualified(_), Delivery::Delivering) => SharingReadiness::Ready,
(Capability::Qualified(_), _) => SharingReadiness::CapableNotDelivering,
(Capability::Disqualified, _) => SharingReadiness::NotCapable,
(Capability::Unknown, _) => SharingReadiness::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cap(
zt: Option<Zaehlertyp>,
fern: Option<bool>,
bm: Option<Bilanzierungsmethode>,
) -> MeteringCapabilityInput {
MeteringCapabilityInput {
zaehlertyp: zt,
ist_fernauslesbar: fern,
bilanzierungsmethode: bm,
smgw_operational: None,
}
}
#[test]
fn rlm_qualifies_without_a_gateway() {
let (c, _) = assess_capability(&cap(
Some(Zaehlertyp::Conventional),
None,
Some(Bilanzierungsmethode::Rlm),
));
assert_eq!(
c,
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung)
);
}
#[test]
fn imsys_qualifies_on_the_zsg_limb() {
let (c, _) = assess_capability(&cap(
Some(Zaehlertyp::IntelligentesMesssystem),
Some(true),
None,
));
assert_eq!(
c,
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung)
);
}
#[test]
fn imsys_that_cannot_be_read_remotely_is_disqualified() {
let (c, reasons) = assess_capability(&cap(
Some(Zaehlertyp::IntelligentesMesssystem),
Some(false),
Some(Bilanzierungsmethode::Ims),
));
assert_eq!(c, Capability::Disqualified);
assert_eq!(reasons, vec![Finding::NotRemotelyReadable]);
}
#[test]
fn moderne_messeinrichtung_is_not_sufficient() {
let (c, reasons) =
assess_capability(&cap(Some(Zaehlertyp::ModerneMesseinrichtung), None, None));
assert_eq!(c, Capability::Disqualified);
assert_eq!(reasons, vec![Finding::ModerneMesseinrichtungWithoutGateway]);
}
#[test]
fn slp_is_disqualified() {
let (c, _) = assess_capability(&cap(
Some(Zaehlertyp::Conventional),
None,
Some(Bilanzierungsmethode::Slp),
));
assert_eq!(c, Capability::Disqualified);
}
#[test]
fn missing_master_data_is_unknown_not_a_guess() {
let (c, reasons) = assess_capability(&cap(None, None, None));
assert_eq!(c, Capability::Unknown);
assert_eq!(
reasons,
vec![
Finding::ZaehlertypMissing,
Finding::BilanzierungsmethodeMissing
],
"both gaps reported"
);
}
#[test]
fn delivery_requires_quarter_hour_resolution() {
let ev = DeliveryEvidenceInput {
resolution: Some(IntervalResolution::Hour),
reading_count: 100,
..Default::default()
};
let (d, reasons) = assess_delivery(&ev, DEFAULT_COVERAGE_THRESHOLD_PCT);
assert_eq!(d, Delivery::Insufficient);
assert_eq!(reasons, vec![Finding::NotQuarterHourResolution]);
let unknown = DeliveryEvidenceInput {
resolution: None,
reading_count: 100,
..Default::default()
};
let (_, reasons) = assess_delivery(&unknown, DEFAULT_COVERAGE_THRESHOLD_PCT);
assert_eq!(reasons, vec![Finding::ResolutionUndeterminable]);
}
#[test]
fn delivery_requires_coverage_above_threshold() {
let ev = DeliveryEvidenceInput {
resolution: Some(IntervalResolution::QuarterHour),
coverage_pct: Some(80.0),
reading_count: 100,
..Default::default()
};
let (d, reasons) = assess_delivery(&ev, DEFAULT_COVERAGE_THRESHOLD_PCT);
assert_eq!(d, Delivery::Insufficient);
assert_eq!(reasons, vec![Finding::CoverageBelowThreshold]);
}
#[test]
fn findings_are_matchable_and_renderable() {
for f in Finding::ALL {
assert!(!f.description_de().is_empty(), "{f:?}");
}
let (_, reasons) = assess_capability(&cap(None, None, None));
assert!(reasons.contains(&Finding::ZaehlertypMissing));
}
#[test]
fn capable_but_silent_is_its_own_verdict() {
let verdict = combine(
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung),
Delivery::Absent,
);
assert_eq!(verdict, SharingReadiness::CapableNotDelivering);
assert_eq!(
verdict.required_action(),
"Zählerstandsgangmessung beauftragen"
);
}
#[test]
fn delivery_alone_does_not_establish_eligibility() {
assert_eq!(
combine(Capability::Unknown, Delivery::Delivering),
SharingReadiness::Unknown
);
}
}