use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::{Date, OffsetDateTime, Weekday};
use time_tz::{OffsetDateTimeExt as _, timezones};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::holiday::Bundesland;
use crate::ids::BdewCode;
use crate::interval::MeterInterval;
pub const HT: &str = "HT";
pub const NT: &str = "NT";
pub const ST: &str = "ST";
pub const ALL_MONTHS: u16 = 0x0FFF;
const MINUTES_PER_DAY: u16 = 24 * 60;
#[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 DayGroup {
Weekdays,
WeekdaysAndSaturday,
AllDays,
}
impl DayGroup {
pub const ALL: [Self; 3] = [Self::Weekdays, Self::WeekdaysAndSaturday, Self::AllDays];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Weekdays => "WEEKDAYS",
Self::WeekdaysAndSaturday => "WEEKDAYS_AND_SATURDAY",
Self::AllDays => "ALL_DAYS",
}
}
#[must_use]
pub const fn contains(self, weekday: Weekday) -> bool {
match self {
Self::Weekdays => !matches!(weekday, Weekday::Saturday | Weekday::Sunday),
Self::WeekdaysAndSaturday => !matches!(weekday, Weekday::Sunday),
Self::AllDays => true,
}
}
}
crate::codes::string_codes! {
DayGroup;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ZaehlzeitFenster {
pub register_id: String,
pub months_mask: u16,
pub days: DayGroup,
pub from_minute: u16,
pub to_minute: u16,
}
impl ZaehlzeitFenster {
#[must_use]
pub fn new(register_id: impl Into<String>, from_minute: u16, to_minute: u16) -> Self {
Self {
register_id: register_id.into(),
months_mask: ALL_MONTHS,
days: DayGroup::AllDays,
from_minute,
to_minute,
}
}
#[must_use]
pub fn spanning(register_id: impl Into<String>, from_minute: u16, to_minute: u16) -> Vec<Self> {
let id = register_id.into();
if from_minute < to_minute {
return vec![Self::new(id, from_minute, to_minute)];
}
if from_minute == to_minute {
return vec![Self::new(id, 0, MINUTES_PER_DAY)];
}
vec![
Self::new(id.clone(), from_minute, MINUTES_PER_DAY),
Self::new(id, 0, to_minute),
]
}
#[must_use]
pub const fn on_days(mut self, days: DayGroup) -> Self {
self.days = days;
self
}
#[must_use]
pub const fn in_months(mut self, mask: u16) -> Self {
self.months_mask = mask;
self
}
fn matches(&self, month0: u8, weekday: Weekday, minute: u16, is_holiday: bool) -> bool {
let day_matches = match self.days {
DayGroup::AllDays => true,
_ if is_holiday => false,
days => days.contains(weekday),
};
self.months_mask & (1 << month0) != 0
&& day_matches
&& minute >= self.from_minute
&& minute < self.to_minute
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Zaehlzeitdefinition {
pub id: String,
#[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>,
pub windows: Vec<ZaehlzeitFenster>,
pub fallback_register: Option<String>,
pub holiday_land: Option<Bundesland>,
pub netzbetreiber: Option<BdewCode>,
}
impl Zaehlzeitdefinition {
#[must_use]
pub fn ht_nt(
id: impl Into<String>,
valid_from: Date,
from_minute: u16,
to_minute: u16,
) -> Self {
Self {
id: id.into(),
valid_from,
valid_to: None,
windows: ZaehlzeitFenster::spanning(HT, from_minute, to_minute)
.into_iter()
.map(|w| w.on_days(DayGroup::Weekdays))
.collect(),
fallback_register: Some(NT.to_owned()),
holiday_land: None,
netzbetreiber: None,
}
}
#[must_use]
pub fn modul_3(
id: impl Into<String>,
valid_from: Date,
hochtarif: (u16, u16),
niedertarif: (u16, u16),
) -> Self {
let mut windows = ZaehlzeitFenster::spanning(HT, hochtarif.0, hochtarif.1);
windows.extend(ZaehlzeitFenster::spanning(NT, niedertarif.0, niedertarif.1));
Self {
id: id.into(),
valid_from,
valid_to: None,
windows,
fallback_register: Some(ST.to_owned()),
holiday_land: None,
netzbetreiber: None,
}
}
#[must_use]
pub fn in_land(mut self, land: Bundesland) -> Self {
self.holiday_land = Some(land);
self
}
#[must_use]
pub const fn published_by(mut self, netzbetreiber: BdewCode) -> Self {
self.netzbetreiber = Some(netzbetreiber);
self
}
#[must_use]
pub const fn until(mut self, valid_to: Date) -> Self {
self.valid_to = Some(valid_to);
self
}
#[must_use]
pub fn is_valid_on(&self, date: Date) -> bool {
date >= self.valid_from && self.valid_to.is_none_or(|end| date <= end)
}
#[must_use]
pub fn registers(&self) -> Vec<&str> {
let mut all: Vec<&str> = self
.windows
.iter()
.map(|w| w.register_id.as_str())
.chain(self.fallback_register.as_deref())
.collect();
all.sort_unstable();
all.dedup();
all
}
#[must_use]
pub fn register_for(&self, ts_utc: OffsetDateTime) -> Option<&str> {
let berlin = ts_utc.to_timezone(timezones::db::europe::BERLIN);
if !self.is_valid_on(berlin.date()) {
return None;
}
let month0 = u8::from(berlin.month()) - 1;
let minute = u16::from(berlin.hour()) * 60 + u16::from(berlin.minute());
let is_holiday = self
.holiday_land
.is_some_and(|land| land.is_holiday(berlin.date()));
self.windows
.iter()
.find(|w| w.matches(month0, berlin.weekday(), minute, is_holiday))
.map(|w| w.register_id.as_str())
.or(self.fallback_register.as_deref())
}
#[must_use]
pub fn split_energy<'a>(
&'a self,
intervals: &[MeterInterval],
) -> BTreeMap<Option<&'a str>, Decimal> {
let mut sums: BTreeMap<Option<&'a str>, Decimal> = BTreeMap::new();
for iv in intervals.iter().filter(|iv| iv.quality.is_billable()) {
*sums
.entry(self.register_for(iv.from))
.or_insert(Decimal::ZERO) += iv.value;
}
sums
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Quarter {
Q1,
Q2,
Q3,
Q4,
}
impl Quarter {
pub const ALL: [Self; 4] = [Self::Q1, Self::Q2, Self::Q3, Self::Q4];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Q1 => "Q1",
Self::Q2 => "Q2",
Self::Q3 => "Q3",
Self::Q4 => "Q4",
}
}
#[must_use]
pub const fn of_month(month: u8) -> Option<Self> {
match month {
1..=3 => Some(Self::Q1),
4..=6 => Some(Self::Q2),
7..=9 => Some(Self::Q3),
10..=12 => Some(Self::Q4),
_ => None,
}
}
#[must_use]
pub const fn months(self) -> [u8; 3] {
match self {
Self::Q1 => [1, 2, 3],
Self::Q2 => [4, 5, 6],
Self::Q3 => [7, 8, 9],
Self::Q4 => [10, 11, 12],
}
}
}
pub const MODUL_3_MIN_HOCHTARIF_MINUTES: u16 = 120;
pub const MODUL_3_MIN_BILLED_QUARTERS: usize = 2;
#[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 Modul3Finding {
RegistersAreNotHtNtSt,
RegisterNeverReached,
TimeNotFullyCovered,
HochtarifBelowTwoHours,
WindowsVaryAcrossTheYear,
FewerThanTwoBilledQuarters,
BilledQuartersUnknown,
ValidityIsNotOneCalendarYear,
#[cfg_attr(feature = "serde", serde(rename = "MODUL_1_NOT_SELECTED"))]
Modul1NotSelected,
RegistrierendeLeistungsmessung,
NoIntelligentesMesssystem,
DeliveryPointDataMissing,
}
impl Modul3Finding {
pub const ALL: [Self; 12] = [
Self::RegistersAreNotHtNtSt,
Self::RegisterNeverReached,
Self::TimeNotFullyCovered,
Self::HochtarifBelowTwoHours,
Self::WindowsVaryAcrossTheYear,
Self::FewerThanTwoBilledQuarters,
Self::BilledQuartersUnknown,
Self::ValidityIsNotOneCalendarYear,
Self::Modul1NotSelected,
Self::RegistrierendeLeistungsmessung,
Self::NoIntelligentesMesssystem,
Self::DeliveryPointDataMissing,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RegistersAreNotHtNtSt => "REGISTERS_ARE_NOT_HT_NT_ST",
Self::RegisterNeverReached => "REGISTER_NEVER_REACHED",
Self::TimeNotFullyCovered => "TIME_NOT_FULLY_COVERED",
Self::HochtarifBelowTwoHours => "HOCHTARIF_BELOW_TWO_HOURS",
Self::WindowsVaryAcrossTheYear => "WINDOWS_VARY_ACROSS_THE_YEAR",
Self::FewerThanTwoBilledQuarters => "FEWER_THAN_TWO_BILLED_QUARTERS",
Self::BilledQuartersUnknown => "BILLED_QUARTERS_UNKNOWN",
Self::ValidityIsNotOneCalendarYear => "VALIDITY_IS_NOT_ONE_CALENDAR_YEAR",
Self::Modul1NotSelected => "MODUL_1_NOT_SELECTED",
Self::RegistrierendeLeistungsmessung => "REGISTRIERENDE_LEISTUNGSMESSUNG",
Self::NoIntelligentesMesssystem => "NO_INTELLIGENTES_MESSSYSTEM",
Self::DeliveryPointDataMissing => "DELIVERY_POINT_DATA_MISSING",
}
}
#[must_use]
pub const fn legal_basis(self) -> &'static str {
match self {
Self::BilledQuartersUnknown | Self::DeliveryPointDataMissing => {
"(not a rule — the input did not say)"
}
_ => "BDEW AWH Modul 3 v1.1 (07.02.2025) §2",
}
}
#[must_use]
pub const fn is_unknown(self) -> bool {
matches!(
self,
Self::BilledQuartersUnknown | Self::DeliveryPointDataMissing
)
}
}
#[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 Modul3Conformance {
Conforms,
Violates,
Unknown,
}
impl Modul3Conformance {
pub const ALL: [Self; 3] = [Self::Conforms, Self::Violates, Self::Unknown];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Conforms => "CONFORMS",
Self::Violates => "VIOLATES",
Self::Unknown => "UNKNOWN",
}
}
}
crate::codes::string_codes! {
Quarter;
Modul3Finding;
Modul3Conformance;
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Modul3Context {
pub billed_quarters: Option<Vec<Quarter>>,
pub modul_1_selected: Option<bool>,
pub registrierende_leistungsmessung: Option<bool>,
pub intelligentes_messsystem: Option<bool>,
}
impl Modul3Context {
#[must_use]
pub fn billed_in(mut self, quarters: impl IntoIterator<Item = Quarter>) -> Self {
self.billed_quarters = Some(quarters.into_iter().collect());
self
}
#[must_use]
pub const fn with_modul_1(mut self, selected: bool) -> Self {
self.modul_1_selected = Some(selected);
self
}
#[must_use]
pub const fn with_registrierende_leistungsmessung(mut self, rlm: bool) -> Self {
self.registrierende_leistungsmessung = Some(rlm);
self
}
#[must_use]
pub const fn with_intelligentes_messsystem(mut self, imsys: bool) -> Self {
self.intelligentes_messsystem = Some(imsys);
self
}
#[must_use]
pub const fn at_a_conforming_delivery_point(self) -> Self {
self.with_modul_1(true)
.with_registrierende_leistungsmessung(false)
.with_intelligentes_messsystem(true)
}
}
#[must_use]
pub fn assess_modul_3(
zzd: &Zaehlzeitdefinition,
ctx: &Modul3Context,
) -> (Modul3Conformance, Vec<Modul3Finding>) {
let mut findings = Vec::new();
if zzd.registers() != vec![HT, NT, ST] {
findings.push(Modul3Finding::RegistersAreNotHtNtSt);
}
let classes = day_classes(zzd);
let mut months = (1u8..=12).map(|month| {
classes
.iter()
.map(|&class| day_profile(zzd, month - 1, class))
.collect::<Vec<_>>()
});
let first = months.next().unwrap_or_default();
if !months.all(|other| other == first) {
findings.push(Modul3Finding::WindowsVaryAcrossTheYear);
}
let minutes = |profile: &DayProfile<'_>, register: &str| -> u16 {
profile.get(&Some(register)).copied().unwrap_or(0)
};
if first.iter().any(|p| p.contains_key(&None)) {
findings.push(Modul3Finding::TimeNotFullyCovered);
}
if first
.iter()
.any(|p| minutes(p, HT) < MODUL_3_MIN_HOCHTARIF_MINUTES)
{
findings.push(Modul3Finding::HochtarifBelowTwoHours);
}
let reachable: std::collections::BTreeSet<&str> = (1u8..=12)
.flat_map(|month| {
classes
.iter()
.flat_map(move |&class| day_profile(zzd, month - 1, class).into_keys())
})
.flatten()
.collect();
if zzd.registers().iter().any(|r| !reachable.contains(r)) {
findings.push(Modul3Finding::RegisterNeverReached);
}
match &ctx.billed_quarters {
None => findings.push(Modul3Finding::BilledQuartersUnknown),
Some(quarters) => {
let distinct: std::collections::BTreeSet<Quarter> = quarters.iter().copied().collect();
if distinct.len() < MODUL_3_MIN_BILLED_QUARTERS {
findings.push(Modul3Finding::FewerThanTwoBilledQuarters);
}
}
}
let starts_a_year = zzd.valid_from.month() == time::Month::January && zzd.valid_from.day() == 1;
let ends_that_year = zzd.valid_to.is_none_or(|end| {
end.year() == zzd.valid_from.year()
&& end.month() == time::Month::December
&& end.day() == 31
});
if !starts_a_year || !ends_that_year {
findings.push(Modul3Finding::ValidityIsNotOneCalendarYear);
}
let mut missing = false;
let mut precondition = |value: Option<bool>, required: bool, breach: Modul3Finding| match value
{
Some(v) if v == required => {}
Some(_) => findings.push(breach),
None => missing = true,
};
precondition(ctx.modul_1_selected, true, Modul3Finding::Modul1NotSelected);
precondition(
ctx.registrierende_leistungsmessung,
false,
Modul3Finding::RegistrierendeLeistungsmessung,
);
precondition(
ctx.intelligentes_messsystem,
true,
Modul3Finding::NoIntelligentesMesssystem,
);
if missing {
findings.push(Modul3Finding::DeliveryPointDataMissing);
}
let verdict = if findings.iter().any(|f| !f.is_unknown()) {
Modul3Conformance::Violates
} else if findings.is_empty() {
Modul3Conformance::Conforms
} else {
Modul3Conformance::Unknown
};
(verdict, findings)
}
type DayProfile<'a> = BTreeMap<Option<&'a str>, u16>;
fn day_classes(zzd: &Zaehlzeitdefinition) -> Vec<(Weekday, bool)> {
let mut classes = vec![
(Weekday::Monday, false),
(Weekday::Saturday, false),
(Weekday::Sunday, false),
];
if zzd.holiday_land.is_some() {
classes.push((Weekday::Monday, true));
}
classes
}
fn day_profile<'a>(
zzd: &'a Zaehlzeitdefinition,
month0: u8,
class: (Weekday, bool),
) -> DayProfile<'a> {
let (weekday, is_holiday) = class;
let mut breakpoints: Vec<u16> = vec![0, MINUTES_PER_DAY];
for w in &zzd.windows {
breakpoints.push(w.from_minute.min(MINUTES_PER_DAY));
breakpoints.push(w.to_minute.min(MINUTES_PER_DAY));
}
breakpoints.sort_unstable();
breakpoints.dedup();
let mut profile = DayProfile::new();
for pair in breakpoints.windows(2) {
let (from, to) = (pair[0], pair[1]);
let register = zzd
.windows
.iter()
.find(|w| w.matches(month0, weekday, from, is_holiday))
.map(|w| w.register_id.as_str())
.or(zzd.fallback_register.as_deref());
*profile.entry(register).or_insert(0) += to - from;
}
profile
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::macros::{date, datetime};
fn iv(from: OffsetDateTime, kwh: Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + time::Duration::minutes(15),
value: kwh,
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn ht_nt_resolves_in_berlin_local_time() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 6 * 60, 22 * 60);
assert_eq!(zzd.register_for(datetime!(2026-01-05 8:00 UTC)), Some(HT));
assert_eq!(
zzd.register_for(datetime!(2026-01-05 21:00 UTC)),
Some(NT),
"22:00 local is the exclusive end bound"
);
assert_eq!(zzd.register_for(datetime!(2026-01-05 20:59 UTC)), Some(HT));
assert_eq!(zzd.register_for(datetime!(2026-06-01 7:00 UTC)), Some(HT));
assert_eq!(
zzd.register_for(datetime!(2026-06-01 20:01 UTC)),
Some(NT),
"20:01 UTC is 22:01 CEST"
);
}
#[test]
fn the_weekday_is_read_in_berlin_too() {
let all_hours = Zaehlzeitdefinition {
windows: vec![
ZaehlzeitFenster::new(HT, 0, MINUTES_PER_DAY).on_days(DayGroup::Weekdays),
],
..Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 0, 0)
};
let saturday_early = datetime!(2026-01-02 23:30 UTC);
assert_eq!(saturday_early.weekday(), Weekday::Friday);
assert_eq!(
all_hours.register_for(saturday_early),
Some(NT),
"Saturday 00:30 Berlin is not a weekday, even though UTC says Friday"
);
let monday_early = datetime!(2026-01-04 23:30 UTC);
assert_eq!(monday_early.weekday(), Weekday::Sunday);
assert_eq!(all_hours.register_for(monday_early), Some(HT));
}
#[test]
fn weekends_fall_to_the_fallback() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 6 * 60, 22 * 60);
assert_eq!(zzd.register_for(datetime!(2026-01-03 9:00 UTC)), Some(NT));
assert_eq!(zzd.register_for(datetime!(2026-01-04 11:00 UTC)), Some(NT));
}
#[test]
fn modul_3_resolves_three_registers() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(0, 6 * 60),
);
assert_eq!(zzd.registers(), vec![HT, NT, ST]);
assert_eq!(zzd.register_for(datetime!(2026-01-05 17:00 UTC)), Some(HT)); assert_eq!(zzd.register_for(datetime!(2026-01-05 2:00 UTC)), Some(NT)); assert_eq!(zzd.register_for(datetime!(2026-01-05 9:00 UTC)), Some(ST)); assert_eq!(zzd.register_for(datetime!(2026-01-05 20:00 UTC)), Some(ST));
assert_eq!(zzd.register_for(datetime!(2026-01-04 17:00 UTC)), Some(HT));
}
#[test]
fn a_band_crossing_midnight_becomes_two_windows() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60), );
assert_eq!(zzd.windows.len(), 3, "one HT window, two NT windows");
assert_eq!(zzd.register_for(datetime!(2026-01-05 22:00 UTC)), Some(NT));
assert_eq!(zzd.register_for(datetime!(2026-01-06 2:00 UTC)), Some(NT));
assert_eq!(zzd.register_for(datetime!(2026-01-06 6:00 UTC)), Some(ST));
assert_eq!(ZaehlzeitFenster::spanning(NT, 6 * 60, 22 * 60).len(), 1);
let whole_day = ZaehlzeitFenster::spanning(NT, 0, 0);
assert_eq!(whole_day.len(), 1);
assert_eq!(
(whole_day[0].from_minute, whole_day[0].to_minute),
(0, 1440)
);
}
#[test]
fn bands_can_start_on_the_half_hour() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60 + 30, 19 * 60 + 30),
(0, 6 * 60),
);
assert_eq!(zzd.register_for(datetime!(2026-01-05 17:29 UTC)), Some(HT));
assert_eq!(zzd.register_for(datetime!(2026-01-05 16:29 UTC)), Some(ST));
}
#[test]
fn a_seasonal_window_only_applies_in_its_months() {
let winter_ht = Zaehlzeitdefinition {
windows: vec![
ZaehlzeitFenster::new(HT, 6 * 60, 22 * 60)
.on_days(DayGroup::Weekdays)
.in_months(0b0000_1100_0000_0111),
],
..Zaehlzeitdefinition::ht_nt("ZZD-SEASON", date!(2026 - 01 - 01), 0, 0)
};
assert_eq!(
winter_ht.register_for(datetime!(2026-01-15 7:00 UTC)),
Some(HT)
);
assert_eq!(
winter_ht.register_for(datetime!(2026-07-16 6:00 UTC)),
Some(NT)
);
}
#[test]
fn a_feiertag_books_into_the_fallback_register() {
let base = Zaehlzeitdefinition::ht_nt("ZZD-FT", date!(2026 - 01 - 01), 6 * 60, 22 * 60);
let midday = datetime!(2026-06-04 8:00 UTC);
assert_eq!(
base.register_for(midday),
Some(HT),
"weekday alone sees an ordinary Thursday"
);
assert_eq!(
base.clone().in_land(Bundesland::By).register_for(midday),
Some(NT),
"a Bavarian Feiertag is not a working day"
);
assert_eq!(
base.clone().in_land(Bundesland::Be).register_for(midday),
Some(HT),
"...and in Berlin it is still a Thursday"
);
let ordinary = datetime!(2026-06-11 8:00 UTC);
for land in [Bundesland::By, Bundesland::Be] {
assert_eq!(
base.clone().in_land(land).register_for(ordinary),
Some(HT),
"{land}"
);
}
}
#[test]
fn an_all_days_window_still_covers_feiertage() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(8 * 60, 12 * 60),
(0, 6 * 60),
)
.in_land(Bundesland::By);
assert_eq!(zzd.register_for(datetime!(2026-06-04 8:00 UTC)), Some(HT));
}
#[test]
fn validity_bounds_are_inclusive() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 6 * 60, 22 * 60)
.until(date!(2026 - 06 - 30));
assert!(zzd.register_for(datetime!(2026-07-01 10:00 UTC)).is_none());
assert!(zzd.register_for(datetime!(2026-06-30 10:00 UTC)).is_some());
assert!(zzd.register_for(datetime!(2025-12-31 10:00 UTC)).is_none());
assert!(zzd.register_for(datetime!(2026-01-01 10:00 UTC)).is_some());
}
#[test]
fn the_register_sums_reconstruct_the_arbeitsmenge() {
use crate::{AggregationConfig, aggregate};
let zzd = Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(0, 6 * 60),
);
let start = crate::calendar::day_start_utc(date!(2026 - 01 - 05));
let intervals: Vec<MeterInterval> = (0..96)
.map(|i| iv(start + time::Duration::minutes(i * 15), dec!(2.5)))
.collect();
let split = zzd.split_energy(&intervals);
let period = aggregate(&intervals, &AggregationConfig::rlm());
assert_eq!(
split.values().sum::<Decimal>(),
period.arbeitsmenge,
"every interval is booked exactly once"
);
assert!(!split.contains_key(&None), "the fallback covers everything");
assert_eq!(split[&Some(HT)], dec!(2.5) * dec!(12));
assert_eq!(split[&Some(NT)], dec!(2.5) * dec!(24));
assert_eq!(split[&Some(ST)], dec!(2.5) * dec!(60));
}
#[test]
fn the_split_holds_across_both_dst_transitions() {
use crate::{AggregationConfig, aggregate};
let zzd = Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(0, 6 * 60),
);
for (day, count, expected_nt) in [
(date!(2026 - 03 - 29), 92i64, 20u32),
(date!(2026 - 07 - 20), 96, 24),
(date!(2026 - 10 - 25), 100, 28),
] {
let start = crate::calendar::day_start_utc(day);
let intervals: Vec<MeterInterval> = (0..count)
.map(|i| iv(start + time::Duration::minutes(i * 15), dec!(1)))
.collect();
let split = zzd.split_energy(&intervals);
let period = aggregate(&intervals, &AggregationConfig::rlm());
assert_eq!(
split.values().sum::<Decimal>(),
period.arbeitsmenge,
"{day}: nothing lost across the transition"
);
assert_eq!(
split[&Some(NT)],
Decimal::from(expected_nt),
"{day}: NT quarter-hours"
);
}
}
#[test]
fn unassigned_energy_is_reported_rather_than_lost() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 02 - 01), 6 * 60, 22 * 60);
let split = zzd.split_energy(&[
iv(datetime!(2026-01-15 8:00 UTC), dec!(3)), iv(datetime!(2026-02-16 8:00 UTC), dec!(4)), ]);
assert_eq!(split[&None], dec!(3));
assert_eq!(split[&Some(HT)], dec!(4));
}
#[test]
fn non_billable_intervals_are_excluded() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 6 * 60, 22 * 60);
let mut faulty = iv(datetime!(2026-01-05 8:00 UTC), dec!(99));
faulty.quality = QualityFlag::Faulty;
let split = zzd.split_energy(&[iv(datetime!(2026-01-05 9:00 UTC), dec!(4)), faulty]);
assert_eq!(split[&Some(HT)], dec!(4));
assert_eq!(split.len(), 1);
}
#[test]
fn registers_are_sorted_and_deduplicated() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60), );
assert_eq!(zzd.registers(), vec![HT, NT, ST]);
}
#[test]
fn day_groups_cover_the_week_as_named() {
use Weekday::{Friday, Monday, Saturday, Sunday, Thursday, Tuesday, Wednesday};
for day in [Monday, Tuesday, Wednesday, Thursday, Friday] {
assert!(DayGroup::Weekdays.contains(day));
}
assert!(!DayGroup::Weekdays.contains(Saturday));
assert!(!DayGroup::Weekdays.contains(Sunday));
assert!(DayGroup::WeekdaysAndSaturday.contains(Saturday));
assert!(!DayGroup::WeekdaysAndSaturday.contains(Sunday));
for day in [Monday, Saturday, Sunday] {
assert!(DayGroup::AllDays.contains(day));
}
}
}
#[cfg(test)]
mod modul_3_conformance_tests {
use super::*;
use time::macros::date;
fn conforming() -> Zaehlzeitdefinition {
Zaehlzeitdefinition::modul_3(
"NB-14A-3",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60),
)
.until(date!(2026 - 12 - 31))
}
fn full_context() -> Modul3Context {
Modul3Context::default()
.billed_in([Quarter::Q1, Quarter::Q4])
.at_a_conforming_delivery_point()
}
#[test]
fn a_well_formed_definition_conforms() {
let (verdict, findings) = assess_modul_3(&conforming(), &full_context());
assert_eq!(verdict, Modul3Conformance::Conforms, "{findings:?}");
assert!(findings.is_empty());
}
#[test]
fn a_hochtarif_under_two_hours_is_a_breach() {
let zzd = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 18 * 60 + 30), (22 * 60, 6 * 60),
)
.until(date!(2026 - 12 - 31));
let (verdict, findings) = assess_modul_3(&zzd, &full_context());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::HochtarifBelowTwoHours),
"{findings:?}"
);
let exact = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 19 * 60),
(22 * 60, 6 * 60),
)
.until(date!(2026 - 12 - 31));
assert_eq!(
assess_modul_3(&exact, &full_context()).0,
Modul3Conformance::Conforms,
);
}
#[test]
fn a_weekday_only_hochtarif_fails_on_sundays() {
let mut zzd = conforming();
for w in &mut zzd.windows {
if w.register_id == HT {
w.days = DayGroup::Weekdays;
}
}
let (verdict, findings) = assess_modul_3(&zzd, &full_context());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::HochtarifBelowTwoHours),
"{findings:?}"
);
}
#[test]
fn a_seasonal_window_varies_across_the_year() {
let mut zzd = conforming();
for w in &mut zzd.windows {
if w.register_id == HT {
w.months_mask = 0b1100_0000_0011;
}
}
let (verdict, findings) = assess_modul_3(&zzd, &full_context());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::WindowsVaryAcrossTheYear),
"{findings:?}"
);
}
#[test]
fn a_missing_fallback_leaves_time_uncovered() {
let mut zzd = conforming();
zzd.fallback_register = None;
let (verdict, findings) = assess_modul_3(&zzd, &full_context());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::TimeNotFullyCovered),
"{findings:?}"
);
assert!(
findings.contains(&Modul3Finding::RegistersAreNotHtNtSt),
"{findings:?}"
);
}
#[test]
fn two_quarters_are_required_but_need_not_be_adjacent() {
let zzd = conforming();
let one = full_context().billed_in([Quarter::Q2]);
let (verdict, findings) = assess_modul_3(&zzd, &one);
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(findings.contains(&Modul3Finding::FewerThanTwoBilledQuarters));
let repeated = full_context().billed_in([Quarter::Q2, Quarter::Q2]);
assert_eq!(
assess_modul_3(&zzd, &repeated).0,
Modul3Conformance::Violates
);
let split = full_context().billed_in([Quarter::Q1, Quarter::Q3]);
assert_eq!(assess_modul_3(&zzd, &split).0, Modul3Conformance::Conforms);
}
#[test]
fn missing_input_is_unknown_rather_than_clean() {
let zzd = conforming();
let (verdict, findings) = assess_modul_3(&zzd, &Modul3Context::default());
assert_eq!(verdict, Modul3Conformance::Unknown);
assert!(findings.contains(&Modul3Finding::BilledQuartersUnknown));
assert!(findings.contains(&Modul3Finding::DeliveryPointDataMissing));
assert!(findings.iter().copied().all(Modul3Finding::is_unknown));
let partial = Modul3Context::default()
.with_modul_1(false)
.with_registrierende_leistungsmessung(false)
.with_intelligentes_messsystem(true);
let (verdict, findings) = assess_modul_3(&zzd, &partial);
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(findings.contains(&Modul3Finding::Modul1NotSelected));
}
#[test]
fn the_delivery_point_preconditions_are_each_reported() {
let zzd = conforming();
let ctx = Modul3Context::default()
.billed_in([Quarter::Q1, Quarter::Q2])
.with_modul_1(false)
.with_registrierende_leistungsmessung(true)
.with_intelligentes_messsystem(false);
let (verdict, findings) = assess_modul_3(&zzd, &ctx);
assert_eq!(verdict, Modul3Conformance::Violates);
for expected in [
Modul3Finding::Modul1NotSelected,
Modul3Finding::RegistrierendeLeistungsmessung,
Modul3Finding::NoIntelligentesMesssystem,
] {
assert!(
findings.contains(&expected),
"{expected} missing: {findings:?}"
);
}
}
#[test]
fn validity_must_describe_one_calendar_year() {
let mid_year = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 04 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60),
);
assert!(
assess_modul_3(&mid_year, &full_context())
.1
.contains(&Modul3Finding::ValidityIsNotOneCalendarYear)
);
let open = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60),
);
assert_eq!(
assess_modul_3(&open, &full_context()).0,
Modul3Conformance::Conforms
);
}
#[test]
fn quarters_partition_the_year() {
let mut seen = Vec::new();
for q in Quarter::ALL {
for m in q.months() {
assert_eq!(Quarter::of_month(m), Some(q));
seen.push(m);
}
}
seen.sort_unstable();
assert_eq!(seen, (1u8..=12).collect::<Vec<_>>());
assert_eq!(Quarter::of_month(0), None);
assert_eq!(Quarter::of_month(13), None);
}
#[test]
fn a_two_register_definition_is_not_modul_3() {
let zzd = Zaehlzeitdefinition::ht_nt("NB-1", date!(2026 - 01 - 01), 6 * 60, 22 * 60);
let (verdict, findings) = assess_modul_3(&zzd, &full_context());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(findings.contains(&Modul3Finding::RegistersAreNotHtNtSt));
}
#[test]
fn a_definition_can_name_the_netzbetreiber_that_published_it() {
let nb: crate::BdewCode = "9900987654321".parse().unwrap();
let zzd = conforming().published_by(nb);
assert_eq!(zzd.netzbetreiber, Some(nb));
assert_eq!(
zzd.netzbetreiber.unwrap().vergabestelle(),
crate::CodeVergabestelle::BdewStrom
);
}
}
#[cfg(test)]
mod modul_3_reachability_tests {
use super::*;
use time::macros::{date, datetime};
fn ctx() -> Modul3Context {
Modul3Context::default()
.billed_in([Quarter::Q1, Quarter::Q4])
.at_a_conforming_delivery_point()
}
#[test]
fn a_wrapping_band_written_as_one_window_is_caught() {
let broken = Zaehlzeitdefinition {
id: "NB-BROKEN".to_owned(),
valid_from: date!(2026 - 01 - 01),
valid_to: Some(date!(2026 - 12 - 31)),
windows: vec![
ZaehlzeitFenster::new(HT, 17 * 60, 20 * 60),
ZaehlzeitFenster::new(NT, 22 * 60, 6 * 60), ],
fallback_register: Some(ST.to_owned()),
holiday_land: None,
netzbetreiber: None,
};
assert_eq!(broken.registers(), vec![HT, NT, ST]);
assert_eq!(
broken.register_for(datetime!(2026-01-05 22:00 UTC)),
Some(ST),
"23:00 local should be NT and is not",
);
let (verdict, findings) = assess_modul_3(&broken, &ctx());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::RegisterNeverReached),
"{findings:?}",
);
let fixed = Zaehlzeitdefinition {
windows: {
let mut w = vec![ZaehlzeitFenster::new(HT, 17 * 60, 20 * 60)];
w.extend(ZaehlzeitFenster::spanning(NT, 22 * 60, 6 * 60));
w
},
..broken
};
assert_eq!(
assess_modul_3(&fixed, &ctx()).0,
Modul3Conformance::Conforms
);
}
#[test]
fn a_seasonal_swap_between_nt_and_st_varies_across_the_year() {
let mut zzd = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60),
)
.until(date!(2026 - 12 - 31));
for w in &mut zzd.windows {
if w.register_id == NT {
w.months_mask = 0b1100_0000_0011;
}
}
let (verdict, findings) = assess_modul_3(&zzd, &ctx());
assert_eq!(verdict, Modul3Conformance::Violates);
assert!(
findings.contains(&Modul3Finding::WindowsVaryAcrossTheYear),
"{findings:?}",
);
assert!(
!findings.contains(&Modul3Finding::HochtarifBelowTwoHours),
"the Hochtarif itself is fine: {findings:?}",
);
assert!(
!findings.contains(&Modul3Finding::TimeNotFullyCovered),
"and every minute still books somewhere: {findings:?}",
);
}
#[test]
fn a_register_used_on_one_day_class_is_reachable() {
let mut zzd = Zaehlzeitdefinition::modul_3(
"NB-1",
date!(2026 - 01 - 01),
(17 * 60, 20 * 60),
(22 * 60, 6 * 60),
)
.until(date!(2026 - 12 - 31));
for w in &mut zzd.windows {
if w.register_id == NT {
w.days = DayGroup::Weekdays;
}
}
let findings = assess_modul_3(&zzd, &ctx()).1;
assert!(
!findings.contains(&Modul3Finding::RegisterNeverReached),
"{findings:?}",
);
assert!(!findings.contains(&Modul3Finding::WindowsVaryAcrossTheYear));
}
}