use crate::series::Series;
use crate::time::calendar::Period;
use crate::time::resolution::Resolution;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EligibilityBasis {
Zaehlerstandsgangmessung,
RegistrierendeLeistungsmessung,
}
impl EligibilityBasis {
pub const ALL: [Self; 2] = [
Self::Zaehlerstandsgangmessung,
Self::RegistrierendeLeistungsmessung,
];
#[must_use]
pub const fn as_str(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)]
pub enum Finding {
NotRemotelyReadable,
GatewayNotOperational,
ModerneMesseinrichtungWithoutGateway,
BalancingMethodHasNoQuarterHourValues,
MeterTypeQualifiesForNeitherLimb,
ZaehlertypMissing,
BilanzierungsmethodeMissing,
NoReadings,
NotQuarterHourResolution,
CoverageBelowThreshold,
}
impl Finding {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotRemotelyReadable => "NOT_REMOTELY_READABLE",
Self::GatewayNotOperational => "GATEWAY_NOT_OPERATIONAL",
Self::ModerneMesseinrichtungWithoutGateway => "MODERNE_MESSEINRICHTUNG_WITHOUT_GATEWAY",
Self::BalancingMethodHasNoQuarterHourValues => {
"BALANCING_METHOD_HAS_NO_QUARTER_HOUR_VALUES"
}
Self::MeterTypeQualifiesForNeitherLimb => "METER_TYPE_QUALIFIES_FOR_NEITHER_LIMB",
Self::ZaehlertypMissing => "ZAEHLERTYP_MISSING",
Self::BilanzierungsmethodeMissing => "BILANZIERUNGSMETHODE_MISSING",
Self::NoReadings => "NO_READINGS",
Self::NotQuarterHourResolution => "NOT_QUARTER_HOUR_RESOLUTION",
Self::CoverageBelowThreshold => "COVERAGE_BELOW_THRESHOLD",
}
}
pub const ALL: [Self; 10] = [
Self::NotRemotelyReadable,
Self::GatewayNotOperational,
Self::ModerneMesseinrichtungWithoutGateway,
Self::BalancingMethodHasNoQuarterHourValues,
Self::MeterTypeQualifiesForNeitherLimb,
Self::ZaehlertypMissing,
Self::BilanzierungsmethodeMissing,
Self::NoReadings,
Self::NotQuarterHourResolution,
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::GatewayNotOperational => {
"Smart-Meter-Gateway nicht in Betrieb — keine Zählerstandsgangmessung"
}
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::CoverageBelowThreshold => "Abdeckung unter der Schwelle",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Zaehlertyp {
IntelligentesMesssystem,
ModerneMesseinrichtung,
Conventional,
}
impl Zaehlertyp {
pub const ALL: [Self; 3] = [
Self::IntelligentesMesssystem,
Self::ModerneMesseinrichtung,
Self::Conventional,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::IntelligentesMesssystem => "INTELLIGENTES_MESSSYSTEM",
Self::ModerneMesseinrichtung => "MODERNE_MESSEINRICHTUNG",
Self::Conventional => "CONVENTIONAL",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
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 as_str(self) -> &'static str {
match self {
Self::Rlm => "RLM",
Self::Slp => "SLP",
Self::Ims => "IMS",
Self::Tlp => "TLP",
Self::Pauschal => "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, 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 Capability {
Qualified(EligibilityBasis),
Disqualified,
Unknown,
}
impl Capability {
#[must_use]
pub const fn basis(self) -> Option<EligibilityBasis> {
match self {
Self::Qualified(b) => Some(b),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum Contradiction {
#[error("a moderne Messeinrichtung cannot be balanced as {0}")]
ModerneMesseinrichtungBalancedAs(Bilanzierungsmethode),
#[error("a conventional meter cannot be balanced as IMS")]
ConventionalBalancedAsIms,
#[error("an operational gateway on a {0} meter")]
GatewayOnNonImsys(Zaehlertyp),
}
pub fn assess_capability(
input: &MeteringCapabilityInput,
) -> Result<(Capability, Vec<Finding>), Contradiction> {
let methode = input.bilanzierungsmethode;
let typ = input.zaehlertyp;
match (typ, methode) {
(
Some(Zaehlertyp::ModerneMesseinrichtung),
Some(m @ (Bilanzierungsmethode::Rlm | Bilanzierungsmethode::Ims)),
) => return Err(Contradiction::ModerneMesseinrichtungBalancedAs(m)),
(Some(Zaehlertyp::Conventional), Some(Bilanzierungsmethode::Ims)) => {
return Err(Contradiction::ConventionalBalancedAsIms);
}
_ => {}
}
if input.smgw_operational == Some(true)
&& let Some(t @ (Zaehlertyp::ModerneMesseinrichtung | Zaehlertyp::Conventional)) = typ
{
return Err(Contradiction::GatewayOnNonImsys(t));
}
if methode == Some(Bilanzierungsmethode::Rlm) {
return Ok((
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung),
Vec::new(),
));
}
let looks_imsys = methode == Some(Bilanzierungsmethode::Ims)
|| typ == Some(Zaehlertyp::IntelligentesMesssystem)
|| input.smgw_operational == Some(true);
if looks_imsys {
let mut findings = Vec::new();
if input.ist_fernauslesbar == Some(false) {
findings.push(Finding::NotRemotelyReadable);
}
if input.smgw_operational == Some(false) {
findings.push(Finding::GatewayNotOperational);
}
if findings.is_empty() {
return Ok((
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung),
findings,
));
}
return Ok((Capability::Disqualified, findings));
}
let mut findings = Vec::new();
if typ == Some(Zaehlertyp::ModerneMesseinrichtung) {
findings.push(Finding::ModerneMesseinrichtungWithoutGateway);
return Ok((Capability::Disqualified, findings));
}
if methode.is_some_and(Bilanzierungsmethode::precludes_quarter_hour_values) {
findings.push(Finding::BalancingMethodHasNoQuarterHourValues);
return Ok((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 Ok((Capability::Disqualified, findings));
}
Ok((Capability::Unknown, findings))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Delivery {
Delivering,
Insufficient,
Absent,
}
impl Delivery {
pub const ALL: [Self; 3] = [Self::Delivering, Self::Insufficient, Self::Absent];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Delivering => "DELIVERING",
Self::Insufficient => "INSUFFICIENT",
Self::Absent => "ABSENT",
}
}
}
pub const DEFAULT_COVERAGE_THRESHOLD_PCT: u8 = 95;
#[must_use]
pub fn assess_delivery(
series: &Series,
window: Period,
coverage_threshold_pct: u8,
) -> (Delivery, Vec<Finding>) {
let present = series
.iter()
.filter(|iv| {
iv.quality().is_billable() && window.start() <= iv.from() && iv.to() <= window.end()
})
.count();
if present == 0 {
return (Delivery::Absent, vec![Finding::NoReadings]);
}
if series.resolution() != Resolution::QUARTER_HOUR {
return (
Delivery::Insufficient,
vec![Finding::NotQuarterHourResolution],
);
}
let expected = window.count(Resolution::QUARTER_HOUR).unwrap_or(0);
let covered = u64::try_from(present)
.unwrap_or(u64::MAX)
.saturating_mul(100);
if covered < u64::from(expected) * u64::from(coverage_threshold_pct) {
return (
Delivery::Insufficient,
vec![Finding::CoverageBelowThreshold],
);
}
(Delivery::Delivering, Vec::new())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SharingReadiness {
Ready,
CapableNotDelivering,
NotCapable,
Unknown,
}
impl SharingReadiness {
pub const ALL: [Self; 4] = [
Self::Ready,
Self::CapableNotDelivering,
Self::NotCapable,
Self::Unknown,
];
#[must_use]
pub const fn as_str(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_readiness(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,
}
}
crate::ids::codes::string_codes! {
EligibilityBasis;
Finding;
Zaehlertyp;
Bilanzierungsmethode;
Delivery;
SharingReadiness;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::time::calendar::DayBoundary;
use rust_decimal::dec;
use time::Duration;
use time::macros::date;
fn cap(
zt: Option<Zaehlertyp>,
fern: Option<bool>,
bm: Option<Bilanzierungsmethode>,
smgw: Option<bool>,
) -> Result<(Capability, Vec<Finding>), Contradiction> {
assess_capability(&MeteringCapabilityInput {
zaehlertyp: zt,
ist_fernauslesbar: fern,
bilanzierungsmethode: bm,
smgw_operational: smgw,
})
}
#[test]
fn rlm_qualifies_without_a_gateway() {
let (c, _) = cap(
Some(Zaehlertyp::Conventional),
None,
Some(Bilanzierungsmethode::Rlm),
None,
)
.unwrap();
assert_eq!(
c,
Capability::Qualified(EligibilityBasis::RegistrierendeLeistungsmessung)
);
}
#[test]
fn imsys_qualifies_on_the_zsg_limb() {
let (c, _) = cap(
Some(Zaehlertyp::IntelligentesMesssystem),
Some(true),
None,
None,
)
.unwrap();
assert_eq!(
c,
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung)
);
}
#[test]
fn an_imsys_whose_gateway_is_down_is_disqualified() {
let (c, findings) = cap(
Some(Zaehlertyp::IntelligentesMesssystem),
Some(true),
Some(Bilanzierungsmethode::Ims),
Some(false),
)
.unwrap();
assert_eq!(c, Capability::Disqualified);
assert_eq!(findings, vec![Finding::GatewayNotOperational]);
}
#[test]
fn imsys_that_cannot_be_read_remotely_is_disqualified() {
let (c, findings) = cap(
Some(Zaehlertyp::IntelligentesMesssystem),
Some(false),
Some(Bilanzierungsmethode::Ims),
None,
)
.unwrap();
assert_eq!(c, Capability::Disqualified);
assert_eq!(findings, vec![Finding::NotRemotelyReadable]);
}
#[test]
fn contradictory_master_data_is_refused() {
assert_eq!(
cap(
Some(Zaehlertyp::ModerneMesseinrichtung),
Some(false),
Some(Bilanzierungsmethode::Rlm),
None
),
Err(Contradiction::ModerneMesseinrichtungBalancedAs(
Bilanzierungsmethode::Rlm
))
);
assert_eq!(
cap(
Some(Zaehlertyp::Conventional),
None,
Some(Bilanzierungsmethode::Ims),
None
),
Err(Contradiction::ConventionalBalancedAsIms)
);
assert_eq!(
cap(
Some(Zaehlertyp::ModerneMesseinrichtung),
None,
None,
Some(true)
),
Err(Contradiction::GatewayOnNonImsys(
Zaehlertyp::ModerneMesseinrichtung
))
);
}
#[test]
fn moderne_messeinrichtung_and_slp_are_not_sufficient() {
let (c, f) = cap(Some(Zaehlertyp::ModerneMesseinrichtung), None, None, None).unwrap();
assert_eq!(
(c, f),
(
Capability::Disqualified,
vec![Finding::ModerneMesseinrichtungWithoutGateway]
)
);
let (c, _) = cap(
Some(Zaehlertyp::Conventional),
None,
Some(Bilanzierungsmethode::Slp),
None,
)
.unwrap();
assert_eq!(c, Capability::Disqualified);
}
#[test]
fn missing_master_data_is_unknown_not_a_guess() {
let (c, findings) = cap(None, None, None, None).unwrap();
assert_eq!(c, Capability::Unknown);
assert_eq!(
findings,
vec![
Finding::ZaehlertypMissing,
Finding::BilanzierungsmethodeMissing
]
);
for f in Finding::ALL {
assert!(!f.description_de().is_empty(), "{f:?}");
}
}
fn day_series(resolution: Resolution, present: usize) -> (Series, Period) {
let day = DayBoundary::Strom.day(date!(2026 - 06 - 01)).unwrap();
let step = resolution.fixed_seconds().map(i64::from).unwrap_or(900);
let ivs = (0..present)
.map(|i| {
let from = day.start() + Duration::seconds(step * i as i64);
MeterInterval::new(
from,
from + Duration::seconds(step),
dec!(1),
QualityFlag::Measured,
)
.unwrap()
})
.collect();
(
Series::new(resolution, DayBoundary::Strom, ivs).unwrap(),
day,
)
}
#[test]
fn delivery_coverage_is_computed_from_the_series() {
let (full, day) = day_series(Resolution::QUARTER_HOUR, 96);
assert_eq!(
assess_delivery(&full, day, DEFAULT_COVERAGE_THRESHOLD_PCT),
(Delivery::Delivering, vec![])
);
let (thin, day) = day_series(Resolution::QUARTER_HOUR, 91);
assert_eq!(
assess_delivery(&thin, day, DEFAULT_COVERAGE_THRESHOLD_PCT),
(
Delivery::Insufficient,
vec![Finding::CoverageBelowThreshold]
)
);
let (hourly, day) = day_series(Resolution::Hour, 24);
assert_eq!(
assess_delivery(&hourly, day, DEFAULT_COVERAGE_THRESHOLD_PCT),
(
Delivery::Insufficient,
vec![Finding::NotQuarterHourResolution]
)
);
}
#[test]
fn readings_outside_the_window_do_not_count() {
let (old, _) = day_series(Resolution::QUARTER_HOUR, 1);
let later = DayBoundary::Strom.day(date!(2026 - 07 - 01)).unwrap();
assert_eq!(
assess_delivery(&old, later, DEFAULT_COVERAGE_THRESHOLD_PCT),
(Delivery::Absent, vec![Finding::NoReadings])
);
}
#[test]
fn capable_but_silent_is_its_own_verdict() {
let verdict = combine_readiness(
Capability::Qualified(EligibilityBasis::Zaehlerstandsgangmessung),
Delivery::Absent,
);
assert_eq!(verdict, SharingReadiness::CapableNotDelivering);
assert_eq!(
combine_readiness(Capability::Unknown, Delivery::Delivering),
SharingReadiness::Unknown
);
}
}