use rust_decimal::Decimal;
use time::OffsetDateTime;
use time_tz::{OffsetDateTimeExt as _, timezones};
use crate::tariff_window::TariffWindowDays;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZaehlzeitFenster {
pub register_id: String,
pub months_mask: u16,
pub days: TariffWindowDays,
pub from_minute: u16,
pub to_minute: u16,
}
pub const ALL_MONTHS: u16 = 0x0FFF;
impl ZaehlzeitFenster {
#[must_use]
fn matches(&self, month0: u8, weekday: time::Weekday, minute: u16) -> bool {
self.months_mask & (1 << month0) != 0
&& self.days.is_ht_day(weekday)
&& minute >= self.from_minute
&& minute < self.to_minute
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Zaehlzeitdefinition {
pub id: String,
pub valid_from: time::Date,
pub valid_to: Option<time::Date>,
pub windows: Vec<ZaehlzeitFenster>,
pub fallback_register: Option<String>,
}
impl Zaehlzeitdefinition {
#[must_use]
pub fn is_valid_on(&self, date: time::Date) -> bool {
date >= self.valid_from && self.valid_to.is_none_or(|end| date <= end)
}
#[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());
self.windows
.iter()
.find(|w| w.matches(month0, berlin.weekday(), minute))
.map(|w| w.register_id.as_str())
.or(self.fallback_register.as_deref())
}
#[must_use]
pub fn split_energy(
&self,
intervals: &[crate::interval::MeterInterval],
) -> std::collections::BTreeMap<Option<String>, Decimal> {
let mut sums: std::collections::BTreeMap<Option<String>, Decimal> =
std::collections::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_kwh;
}
sums
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interval::{MeterInterval, QualityFlag};
use rust_decimal::dec;
use time::macros::{date, datetime};
fn winter_ht() -> Zaehlzeitdefinition {
Zaehlzeitdefinition {
id: "ZZD-TEST-1".to_owned(),
valid_from: date!(2026 - 01 - 01),
valid_to: None,
windows: vec![ZaehlzeitFenster {
register_id: "HT".to_owned(),
months_mask: 0b0000_0110_0000_0111,
days: TariffWindowDays::WeekdaysOnly,
from_minute: 6 * 60,
to_minute: 22 * 60,
}],
fallback_register: Some("NT".to_owned()),
}
}
#[test]
fn winter_weekday_morning_is_ht_summer_is_nt() {
let zzd = winter_ht();
assert_eq!(
zzd.register_for(datetime!(2026-01-15 07:00 UTC)),
Some("HT")
);
assert_eq!(
zzd.register_for(datetime!(2026-07-16 06:00 UTC)),
Some("NT")
);
assert_eq!(
zzd.register_for(datetime!(2026-01-18 07:00 UTC)),
Some("NT")
);
}
#[test]
fn dst_boundary_resolves_in_berlin_local_time() {
let zzd = winter_ht();
assert_eq!(
zzd.register_for(datetime!(2026-03-30 05:30 UTC)),
Some("HT")
);
}
#[test]
fn energy_split_books_every_billable_interval() {
let zzd = winter_ht();
let iv = |from: OffsetDateTime, kwh| MeterInterval {
from,
to: from + time::Duration::minutes(15),
value_kwh: kwh,
quality: QualityFlag::Measured,
obis_code: None,
};
let split = zzd.split_energy(&[
iv(datetime!(2026-01-15 07:00 UTC), dec!(2)), iv(datetime!(2026-01-15 23:30 UTC), dec!(1)), iv(datetime!(2026-01-18 07:00 UTC), dec!(4)), ]);
assert_eq!(split.get(&Some("HT".to_owned())), Some(&dec!(2)));
assert_eq!(split.get(&Some("NT".to_owned())), Some(&dec!(5)));
assert!(!split.contains_key(&None), "everything was covered");
}
#[test]
fn validity_bounds_are_inclusive() {
let mut zzd = winter_ht();
zzd.valid_to = Some(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());
}
}