use time::OffsetDateTime;
use crate::classification::{IntervalLengthClass, Messtyp};
#[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, Default)]
pub struct MeteringCapabilityInput {
pub zaehlertyp: Option<String>,
pub ist_fernauslesbar: Option<bool>,
pub bilanzierungsmethode: Option<String>,
pub smgw_operational: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[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<String>) {
let mut reasons = Vec::new();
let methode = input.bilanzierungsmethode.as_deref().map(str::trim);
let typ = input.zaehlertyp.as_deref().map(str::trim);
if methode == Some("RLM") {
return (
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung),
reasons,
);
}
let looks_imsys = methode == Some("IMS")
|| typ == Some("INTELLIGENTES_MESSSYSTEM")
|| input.smgw_operational == Some(true);
if looks_imsys {
if input.ist_fernauslesbar == Some(false) {
reasons.push(
"Zähler ist als nicht fernauslesbar gekennzeichnet — \
keine Zählerstandsgangmessung möglich"
.to_owned(),
);
return (Capability::Disqualified, reasons);
}
return (
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung),
reasons,
);
}
match typ {
Some("MODERNE_MESSEINRICHTUNG") => {
reasons.push(
"moderne Messeinrichtung ohne Smart-Meter-Gateway (§2 Satz 1 Nr. 15 MsbG) — \
iMSys-Rollout oder RLM erforderlich"
.to_owned(),
);
(Capability::Disqualified, reasons)
}
Some(_) if matches!(methode, Some("SLP") | Some("PAUSCHAL")) => {
reasons.push(format!(
"Bilanzierungsmethode {} liefert keine Viertelstundenwerte",
methode.unwrap_or("?")
));
(Capability::Disqualified, reasons)
}
_ => {
if typ.is_none() {
reasons.push("kein Zählertyp im Stammdatensatz hinterlegt".to_owned());
}
if methode.is_none() {
reasons.push("keine Bilanzierungsmethode an der Marktlokation".to_owned());
}
if reasons.is_empty() {
reasons.push(format!(
"Zählertyp {} ist weder iMSys noch RLM",
typ.unwrap_or("?")
));
return (Capability::Disqualified, reasons);
}
(Capability::Unknown, reasons)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DeliveryEvidenceInput {
pub interval_class: Option<IntervalLengthClass>,
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))]
#[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<String>) {
let mut reasons = Vec::new();
if input.reading_count == 0 {
reasons.push("keine Messwerte im Betrachtungszeitraum".to_owned());
return (Delivery::Absent, reasons);
}
if input.interval_class != Some(IntervalLengthClass::FifteenMin) {
reasons.push(match input.interval_class {
Some(c) => format!(
"Messwerte liegen in {}-Sekunden-Intervallen vor, nicht viertelstündlich",
c.seconds()
),
None => "Intervalllänge konnte nicht bestimmt werden".to_owned(),
});
return (Delivery::Insufficient, reasons);
}
if let Some(cov) = input.coverage_pct
&& cov < coverage_threshold_pct
{
reasons.push(format!(
"Abdeckung {cov:.1} % unter Schwelle {coverage_threshold_pct:.1} %"
));
return (Delivery::Insufficient, reasons);
}
(Delivery::Delivering, reasons)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[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<&str>, fern: Option<bool>, bm: Option<&str>) -> MeteringCapabilityInput {
MeteringCapabilityInput {
zaehlertyp: zt.map(str::to_owned),
ist_fernauslesbar: fern,
bilanzierungsmethode: bm.map(str::to_owned),
smgw_operational: None,
}
}
#[test]
fn rlm_qualifies_without_a_gateway() {
let (c, _) = assess_capability(&cap(Some("DREHSTROMZAEHLER"), None, Some("RLM")));
assert_eq!(
c,
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung)
);
}
#[test]
fn imsys_qualifies_on_the_zsg_limb() {
let (c, _) = assess_capability(&cap(Some("INTELLIGENTES_MESSSYSTEM"), 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("INTELLIGENTES_MESSSYSTEM"),
Some(false),
Some("IMS"),
));
assert_eq!(c, Capability::Disqualified);
assert!(reasons[0].contains("fernauslesbar"), "{reasons:?}");
}
#[test]
fn moderne_messeinrichtung_is_not_sufficient() {
let (c, reasons) = assess_capability(&cap(Some("MODERNE_MESSEINRICHTUNG"), None, None));
assert_eq!(c, Capability::Disqualified);
assert!(reasons[0].contains("Gateway"), "{reasons:?}");
}
#[test]
fn slp_is_disqualified() {
let (c, _) = assess_capability(&cap(Some("DREHSTROMZAEHLER"), None, Some("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.len(), 2, "both gaps reported: {reasons:?}");
}
#[test]
fn delivery_requires_quarter_hour_resolution() {
let ev = DeliveryEvidenceInput {
interval_class: Some(IntervalLengthClass::SixtyMin),
reading_count: 100,
..Default::default()
};
let (d, reasons) = assess_delivery(&ev, DEFAULT_COVERAGE_THRESHOLD_PCT);
assert_eq!(d, Delivery::Insufficient);
assert!(reasons[0].contains("3600"), "{reasons:?}");
}
#[test]
fn delivery_requires_coverage_above_threshold() {
let ev = DeliveryEvidenceInput {
interval_class: Some(IntervalLengthClass::FifteenMin),
coverage_pct: Some(80.0),
reading_count: 100,
..Default::default()
};
let (d, _) = assess_delivery(&ev, DEFAULT_COVERAGE_THRESHOLD_PCT);
assert_eq!(d, Delivery::Insufficient);
}
#[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
);
}
}