use metering::IntervalResolution;
use metering::calendar::DayBoundary;
use metering::interval::Sparte;
use time::{Date, Duration, OffsetDateTime};
#[must_use]
pub const fn day_boundary(sparte: Sparte) -> DayBoundary {
match sparte {
Sparte::Gas => DayBoundary::Gastag,
_ => DayBoundary::Midnight,
}
}
#[must_use]
pub fn balancing_day(instant: OffsetDateTime, sparte: Sparte) -> Date {
day_boundary(sparte).local_day(instant)
}
#[must_use]
pub fn balancing_month(instant: OffsetDateTime, sparte: Sparte) -> Date {
day_boundary(sparte).local_month(instant)
}
#[must_use]
pub fn balancing_day_bounds(day: Date, sparte: Sparte) -> (OffsetDateTime, OffsetDateTime) {
let boundary = day_boundary(sparte);
(boundary.day_start_utc(day), boundary.day_end_utc(day))
}
#[must_use]
pub fn balancing_day_length(day: Date, sparte: Sparte) -> Duration {
day_boundary(sparte).day_length(day)
}
#[must_use]
pub fn gas_day_length(day: Date) -> Duration {
DayBoundary::Gastag.day_length(day)
}
#[must_use]
pub fn intervals_in_gas_day(day: Date, resolution: IntervalResolution) -> Option<u32> {
DayBoundary::Gastag.intervals_in_day(day, resolution)
}
#[must_use]
pub fn expected_intervals_in_balancing_day(
day: Date,
resolution: IntervalResolution,
sparte: Sparte,
) -> Option<u32> {
day_boundary(sparte).intervals_in_day(day, resolution)
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
#[test]
fn the_only_rule_this_module_owns_is_the_sparte_mapping() {
assert_eq!(day_boundary(Sparte::Gas), DayBoundary::Gastag);
for other in [Sparte::Strom, Sparte::Waerme, Sparte::Wasser] {
assert_eq!(day_boundary(other), DayBoundary::Midnight);
}
}
#[test]
fn the_gas_day_starts_six_hours_late() {
let before = datetime!(2026-07-15 3:00 UTC); let after = datetime!(2026-07-15 5:00 UTC);
assert_eq!(balancing_day(before, Sparte::Gas), date!(2026 - 07 - 14));
assert_eq!(balancing_day(after, Sparte::Gas), date!(2026 - 07 - 15));
for at in [before, after] {
assert_eq!(balancing_day(at, Sparte::Strom), date!(2026 - 07 - 15));
}
}
#[test]
fn the_bilanzierungsmonat_carries_the_boundary_up() {
let early = datetime!(2026-03-01 1:00 UTC); assert_eq!(balancing_month(early, Sparte::Strom), date!(2026 - 03 - 01));
assert_eq!(balancing_month(early, Sparte::Gas), date!(2026 - 02 - 01));
let later = datetime!(2026-03-01 6:00 UTC); for sparte in [Sparte::Strom, Sparte::Gas] {
assert_eq!(balancing_month(later, sparte), date!(2026 - 03 - 01));
}
}
#[test]
fn a_balancing_month_is_the_month_of_the_balancing_day() {
let mut at = datetime!(2026-01-01 00:00 UTC);
let end = datetime!(2027-01-01 00:00 UTC);
while at < end {
for sparte in [Sparte::Strom, Sparte::Gas] {
let day = balancing_day(at, sparte);
let month = balancing_month(at, sparte);
assert_eq!(month.year(), day.year(), "{at} {sparte}");
assert_eq!(month.month(), day.month(), "{at} {sparte}");
assert_eq!(month.day(), 1, "{at} {sparte}");
}
at += Duration::hours(7);
}
}
#[test]
fn consecutive_balancing_days_tile_the_timeline() {
for sparte in [Sparte::Strom, Sparte::Gas] {
for start in [date!(2026 - 03 - 28), date!(2026 - 10 - 23)] {
let mut day = start;
for _ in 0..4 {
let (_, end) = balancing_day_bounds(day, sparte);
let next = day.next_day().expect("in range");
let (next_start, _) = balancing_day_bounds(next, sparte);
assert_eq!(end, next_start, "{day} -> {next} ({sparte})");
day = next;
}
}
}
}
#[test]
fn the_dst_anomaly_sits_on_a_different_named_day_for_gas() {
let q = IntervalResolution::QuarterHour;
assert_eq!(intervals_in_gas_day(date!(2026 - 10 - 24), q), Some(100));
assert_eq!(intervals_in_gas_day(date!(2026 - 10 - 25), q), Some(96));
assert_eq!(
expected_intervals_in_balancing_day(date!(2026 - 10 - 25), q, Sparte::Strom),
Some(100)
);
assert_eq!(intervals_in_gas_day(date!(2026 - 03 - 28), q), Some(92));
assert_eq!(intervals_in_gas_day(date!(2026 - 03 - 29), q), Some(96));
assert_eq!(
expected_intervals_in_balancing_day(date!(2026 - 03 - 29), q, Sparte::Strom),
Some(92)
);
}
#[test]
fn a_daily_resolution_expects_one_interval_on_both_boundaries() {
for sparte in [Sparte::Strom, Sparte::Gas] {
assert_eq!(
expected_intervals_in_balancing_day(
date!(2026 - 07 - 15),
IntervalResolution::Day,
sparte
),
Some(1),
"{sparte}"
);
for coarse in [IntervalResolution::Month, IntervalResolution::Year] {
assert_eq!(
expected_intervals_in_balancing_day(date!(2026 - 07 - 15), coarse, sparte),
None,
"{sparte} {coarse:?}"
);
}
}
}
#[test]
fn sub_quarter_hour_gas_resolutions_scale_with_the_day() {
let minute = IntervalResolution::from_seconds(60).expect("a minute is a resolution");
assert_eq!(
intervals_in_gas_day(date!(2026 - 07 - 15), minute),
Some(1_440)
);
assert_eq!(
intervals_in_gas_day(date!(2026 - 10 - 24), minute),
Some(1_500)
);
}
#[test]
fn day_length_and_bounds_agree() {
for sparte in [Sparte::Strom, Sparte::Gas] {
for day in [
date!(2026 - 07 - 15),
date!(2026 - 03 - 28),
date!(2026 - 10 - 24),
] {
let (start, end) = balancing_day_bounds(day, sparte);
assert_eq!(balancing_day_length(day, sparte), end - start);
}
}
}
}