use metering::IntervalResolution;
use metering::calendar;
use metering::interval::Sparte;
use time::{Date, Duration, OffsetDateTime};
#[must_use]
pub fn balancing_day(instant: OffsetDateTime, sparte: Sparte) -> Date {
match sparte {
Sparte::Gas => calendar::local_gas_day(instant),
_ => calendar::local_day(instant),
}
}
#[must_use]
pub fn balancing_day_bounds(day: Date, sparte: Sparte) -> (OffsetDateTime, OffsetDateTime) {
match sparte {
Sparte::Gas => (
calendar::gas_day_start_utc(day),
calendar::gas_day_end_utc(day),
),
_ => (calendar::day_start_utc(day), calendar::day_end_utc(day)),
}
}
#[must_use]
pub fn balancing_day_length(day: Date, sparte: Sparte) -> Duration {
let (start, end) = balancing_day_bounds(day, sparte);
end - start
}
#[must_use]
pub fn gas_day_length(day: Date) -> Duration {
calendar::gas_day_end_utc(day) - calendar::gas_day_start_utc(day)
}
#[must_use]
pub fn intervals_in_gas_day(day: Date, resolution: IntervalResolution) -> Option<u32> {
let step = i64::from(resolution.fixed_seconds()?);
u32::try_from(gas_day_length(day).whole_seconds() / step).ok()
}
#[must_use]
pub fn expected_intervals_in_balancing_day(
day: Date,
resolution: IntervalResolution,
sparte: Sparte,
) -> Option<u32> {
match sparte {
Sparte::Gas => intervals_in_gas_day(day, resolution),
_ => calendar::intervals_in_day(day, resolution),
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
#[test]
fn gas_reads_before_0600_belong_to_the_previous_gastag() {
let just_after_midnight = datetime!(2026-07-14 22:15 UTC); assert_eq!(
balancing_day(just_after_midnight, Sparte::Strom),
date!(2026 - 07 - 15)
);
assert_eq!(
balancing_day(just_after_midnight, Sparte::Gas),
date!(2026 - 07 - 14)
);
}
#[test]
fn heat_and_water_follow_the_calendar_day() {
let at = datetime!(2026-07-15 3:00 UTC); for sparte in [Sparte::Strom, Sparte::Waerme, Sparte::Wasser] {
assert_eq!(balancing_day(at, sparte), date!(2026 - 07 - 15), "{sparte}");
}
assert_eq!(balancing_day(at, Sparte::Gas), date!(2026 - 07 - 14));
}
#[test]
fn the_dst_anomaly_lands_on_saturdays_gastag() {
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!(
calendar::intervals_in_day(date!(2026 - 10 - 24), q),
Some(96)
);
assert_eq!(
calendar::intervals_in_day(date!(2026 - 10 - 25), q),
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));
}
#[test]
fn gas_days_tile_the_timeline_across_both_transitions() {
for (mut day, last) in [
(date!(2026 - 03 - 26), date!(2026 - 04 - 01)),
(date!(2026 - 10 - 22), date!(2026 - 10 - 28)),
] {
let mut cursor = balancing_day_bounds(day, Sparte::Gas).0;
while day < last {
let (start, end) = balancing_day_bounds(day, Sparte::Gas);
assert_eq!(start, cursor, "gap or overlap before {day}");
cursor = end;
day = day.next_day().unwrap();
}
}
}
#[test]
fn every_instant_lies_inside_the_day_it_is_attributed_to() {
let mut at = datetime!(2026-10-24 0:00 UTC);
while at < datetime!(2026-10-27 0:00 UTC) {
for sparte in [Sparte::Strom, Sparte::Gas] {
let day = balancing_day(at, sparte);
let (start, end) = balancing_day_bounds(day, sparte);
assert!(
start <= at && at < end,
"{at} not inside {day} for {sparte}"
);
}
at += Duration::minutes(15);
}
}
#[test]
fn a_calendar_resolution_has_no_count_within_a_gas_day() {
for res in [
IntervalResolution::Day,
IntervalResolution::Month,
IntervalResolution::Year,
] {
assert_eq!(intervals_in_gas_day(date!(2026 - 07 - 15), res), None);
assert_eq!(
expected_intervals_in_balancing_day(date!(2026 - 07 - 15), res, Sparte::Gas),
None
);
}
}
#[test]
fn sub_quarter_hour_gas_resolutions_scale_with_the_day() {
let minute = IntervalResolution::Custom(60);
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)
);
}
}