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::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,
pub valid_from: Date,
pub valid_to: Option<Date>,
pub windows: Vec<ZaehlzeitFenster>,
pub fallback_register: Option<String>,
pub holiday_land: Option<Bundesland>,
}
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,
}
}
#[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,
}
}
#[must_use]
pub fn in_land(mut self, land: Bundesland) -> Self {
self.holiday_land = Some(land);
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(&self, intervals: &[MeterInterval]) -> BTreeMap<Option<String>, Decimal> {
let mut sums: BTreeMap<Option<String>, Decimal> = BTreeMap::new();
for iv in intervals.iter().filter(|iv| iv.quality.is_billable()) {
let register = self.register_for(iv.from).map(str::to_owned);
*sums.entry(register).or_insert(Decimal::ZERO) += iv.value;
}
sums
}
}
#[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.to_owned())], dec!(2.5) * dec!(12));
assert_eq!(split[&Some(NT.to_owned())], dec!(2.5) * dec!(24));
assert_eq!(split[&Some(ST.to_owned())], 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.to_owned())],
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.to_owned())], 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.to_owned())], 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));
}
}
}