use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::calendar::DayBoundary;
use crate::interval::{MeterInterval, QualityFlag};
use crate::resolution::IntervalResolution;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct ResampledBucket {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub total: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_option"))]
pub peak_kw: Option<Decimal>,
pub interval_count: u32,
pub expected_count: Option<u32>,
pub quality: QualityFlag,
}
impl ResampledBucket {
#[must_use]
pub fn coverage_pct(&self) -> Option<f64> {
let expected = self.expected_count?;
if expected == 0 {
return None;
}
Some(f64::from(self.interval_count) / f64::from(expected) * 100.0)
}
#[must_use]
pub fn is_complete(&self) -> Option<bool> {
Some(self.interval_count == self.expected_count?)
}
#[must_use]
pub fn has_missing_data(&self) -> bool {
self.expected_count
.is_some_and(|expected| self.interval_count < expected)
}
#[must_use]
pub fn has_surplus_data(&self) -> bool {
self.expected_count
.is_some_and(|expected| self.interval_count > expected)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResampleConfig {
pub target_resolution: IntervalResolution,
pub source_resolution: IntervalResolution,
pub day_boundary: DayBoundary,
}
impl ResampleConfig {
#[must_use]
pub const fn new(source: IntervalResolution, target: IntervalResolution) -> Self {
Self {
target_resolution: target,
source_resolution: source,
day_boundary: DayBoundary::Midnight,
}
}
#[must_use]
pub const fn on(mut self, boundary: DayBoundary) -> Self {
self.day_boundary = boundary;
self
}
#[must_use]
pub const fn to_gas_daily() -> Self {
Self::new(IntervalResolution::Hour, IntervalResolution::Day).on(DayBoundary::Gastag)
}
#[must_use]
pub const fn to_hourly() -> Self {
Self::new(IntervalResolution::QuarterHour, IntervalResolution::Hour)
}
#[must_use]
pub const fn to_daily() -> Self {
Self::new(IntervalResolution::QuarterHour, IntervalResolution::Day)
}
#[must_use]
pub const fn to_monthly() -> Self {
Self::new(IntervalResolution::QuarterHour, IntervalResolution::Month)
}
#[must_use]
pub const fn to_yearly() -> Self {
Self::new(IntervalResolution::QuarterHour, IntervalResolution::Year)
}
}
impl Default for ResampleConfig {
fn default() -> Self {
Self::to_hourly()
}
}
#[must_use]
pub fn resample(intervals: &[MeterInterval], config: &ResampleConfig) -> Vec<ResampledBucket> {
if intervals.is_empty() {
return Vec::new();
}
let mut buckets: BTreeMap<i64, ResampledBucket> = BTreeMap::new();
for iv in intervals {
let (bucket_start, bucket_end) = config
.day_boundary
.bucket_bounds(iv.from, config.target_resolution);
let entry = buckets
.entry(bucket_start.unix_timestamp())
.or_insert_with(|| ResampledBucket {
from: bucket_start,
to: bucket_end,
total: Decimal::ZERO,
peak_kw: None,
interval_count: 0,
expected_count: crate::calendar::intervals_between(
bucket_start,
bucket_end,
config.source_resolution,
),
quality: QualityFlag::Measured,
});
entry.total += iv.value;
entry.interval_count += 1;
let duration_secs = (iv.to - iv.from).whole_seconds().max(1);
let duration_h = Decimal::from(duration_secs) / Decimal::from(3_600u32);
if duration_h > Decimal::ZERO {
let kw = iv.value / duration_h;
entry.peak_kw = Some(entry.peak_kw.map_or(kw, |prev| prev.max(kw)));
}
entry.quality = entry.quality.worse_of(iv.quality);
}
buckets.into_values().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn make_iv(from: OffsetDateTime, value: Decimal) -> MeterInterval {
MeterInterval {
from,
to: from + Duration::minutes(15),
value,
quality: QualityFlag::Measured,
obis_code: None,
}
}
#[test]
fn four_quarters_sum_to_one_hour() {
let base = datetime!(2026-01-01 00:00 UTC);
let ivs: Vec<_> = (0..4)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(2.5)))
.collect();
let result = resample(&ivs, &ResampleConfig::to_hourly());
assert_eq!(result.len(), 1);
assert_eq!(result[0].total, dec!(10.0));
assert_eq!(result[0].interval_count, 4);
assert_eq!(result[0].expected_count, Some(4));
assert_eq!(result[0].is_complete(), Some(true));
}
#[test]
fn gap_in_bucket_sets_has_missing_data() {
let base = datetime!(2026-01-01 00:00 UTC);
let ivs: Vec<_> =
(0..3) .map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&ivs, &ResampleConfig::to_hourly());
assert_eq!(result.len(), 1);
assert!(result[0].has_missing_data());
assert_eq!(result[0].is_complete(), Some(false));
}
#[test]
fn daily_aggregation_96_intervals() {
let base = crate::calendar::day_start_utc(date!(2026 - 03 - 15));
let ivs: Vec<_> = (0..96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&ivs, &ResampleConfig::to_daily());
assert_eq!(result.len(), 1, "one Berlin calendar day");
assert_eq!(result[0].total, dec!(96.0));
assert_eq!(result[0].expected_count, Some(96));
assert_eq!(result[0].is_complete(), Some(true));
}
#[test]
fn a_utc_day_is_not_a_german_day() {
let base = datetime!(2026-03-15 00:00 UTC); let ivs: Vec<_> = (0..96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&ivs, &ResampleConfig::to_daily());
assert_eq!(result.len(), 2, "a UTC day straddles two German days");
assert_eq!(result[0].interval_count, 92, "01:00 CET to midnight");
assert_eq!(result[1].interval_count, 4, "midnight to 01:00 CET");
assert!(result.iter().all(|b| b.is_complete() == Some(false)));
assert_eq!(
result[0].from,
crate::calendar::day_start_utc(date!(2026 - 03 - 15))
);
}
#[test]
fn dst_days_expect_92_and_100_intervals() {
for (day, expected) in [
(date!(2026 - 03 - 29), 92u32),
(date!(2026 - 10 - 25), 100u32),
] {
let base = crate::calendar::day_start_utc(day);
let full: Vec<_> = (0..i64::from(expected))
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&full, &ResampleConfig::to_daily());
assert_eq!(result.len(), 1, "{day}");
assert_eq!(
result[0].expected_count,
Some(expected),
"{day} expected_count"
);
assert_eq!(
result[0].is_complete(),
Some(true),
"{day} must be complete"
);
assert!(!result[0].has_missing_data(), "{day}");
}
let base = crate::calendar::day_start_utc(date!(2026 - 10 - 25));
let short: Vec<_> = (0..96)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&short, &ResampleConfig::to_daily());
assert!(
result[0].has_missing_data(),
"96 of 100 intervals must not read as a complete autumn day"
);
assert_eq!(result[0].expected_count, Some(100));
}
#[test]
fn month_buckets_follow_the_german_calendar() {
let base = datetime!(2026-01-31 23:30 UTC); let ivs: Vec<_> = (0..4)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
let result = resample(&ivs, &ResampleConfig::to_monthly());
assert_eq!(result.len(), 1, "all four intervals are February");
assert_eq!(
result[0].from,
crate::calendar::month_start_utc(date!(2026 - 02 - 01))
);
assert_eq!(result[0].expected_count, Some(28 * 96));
let straddling = vec![
make_iv(datetime!(2026-01-31 22:45 UTC), dec!(1.0)), make_iv(datetime!(2026-01-31 23:00 UTC), dec!(1.0)), ];
let result = resample(&straddling, &ResampleConfig::to_monthly());
assert_eq!(result.len(), 2);
}
#[test]
fn month_expected_count_absorbs_the_dst_hour() {
let march = resample(
&[make_iv(datetime!(2026-03-15 12:00 UTC), dec!(1.0))],
&ResampleConfig::to_monthly(),
);
assert_eq!(march[0].expected_count, Some(2_972));
let october = resample(
&[make_iv(datetime!(2026-10-15 12:00 UTC), dec!(1.0))],
&ResampleConfig::to_monthly(),
);
assert_eq!(october[0].expected_count, Some(2_980));
}
#[test]
fn yearly_buckets_account_for_leap_days() {
let leap = resample(
&[make_iv(datetime!(2028-06-15 12:00 UTC), dec!(1.0))],
&ResampleConfig::to_yearly(),
);
assert_eq!(leap[0].expected_count, Some(366 * 96));
assert_eq!(leap[0].from, crate::calendar::year_start_utc(2028));
}
#[test]
fn peak_kw_is_maximum_across_intervals() {
let base = datetime!(2026-06-01 10:00 UTC);
let ivs = vec![
make_iv(base, dec!(5.0)), make_iv(base + Duration::minutes(15), dec!(2.5)), ];
let result = resample(&ivs, &ResampleConfig::to_hourly());
assert_eq!(result[0].peak_kw, Some(dec!(20.0)));
}
#[test]
fn worst_quality_propagates() {
let base = datetime!(2026-01-01 00:00 UTC);
let mut ivs: Vec<_> = (0..4)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
ivs[2].quality = QualityFlag::Estimated;
let result = resample(&ivs, &ResampleConfig::to_hourly());
assert_eq!(result[0].quality, QualityFlag::Estimated);
}
#[test]
fn an_unknown_expected_count_is_not_a_complete_bucket() {
let cfg = ResampleConfig::new(IntervalResolution::Day, IntervalResolution::Month);
let daily = vec![MeterInterval {
from: crate::calendar::day_start_utc(date!(2026 - 03 - 15)),
to: crate::calendar::day_end_utc(date!(2026 - 03 - 15)),
value: dec!(100),
quality: QualityFlag::Measured,
obis_code: None,
}];
let result = resample(&daily, &cfg);
assert_eq!(result[0].expected_count, None);
assert_eq!(result[0].is_complete(), None, "unknown, not complete");
assert_eq!(result[0].coverage_pct(), None, "unknown, not 100 %");
assert!(!result[0].has_missing_data());
assert!(!result[0].has_surplus_data());
}
#[test]
fn a_surplus_is_visible_rather_than_capped() {
let base = datetime!(2026-01-01 00:00 UTC);
let mut ivs: Vec<_> = (0..4)
.map(|i| make_iv(base + Duration::minutes(15 * i), dec!(1.0)))
.collect();
ivs.extend(ivs.clone());
let result = resample(&ivs, &ResampleConfig::to_hourly());
assert_eq!(result[0].interval_count, 8);
assert_eq!(result[0].expected_count, Some(4));
assert!(result[0].has_surplus_data());
assert!(!result[0].has_missing_data());
assert_eq!(result[0].is_complete(), Some(false));
assert!(
result[0].coverage_pct().unwrap() > 100.0,
"coverage is not capped: {:?}",
result[0].coverage_pct()
);
}
#[test]
fn empty_input_returns_empty() {
assert!(resample(&[], &ResampleConfig::to_hourly()).is_empty());
}
#[test]
fn coverage_pct_partial_bucket() {
let base = datetime!(2026-01-01 00:00 UTC);
let ivs = vec![make_iv(base, dec!(1.0))]; let result = resample(&ivs, &ResampleConfig::to_hourly());
assert!((result[0].coverage_pct().unwrap() - 25.0).abs() < 0.01);
}
}
#[cfg(test)]
mod gas_day_tests {
use super::*;
use crate::calendar;
use crate::interval::QualityFlag;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn hourly(start: OffsetDateTime, n: i64) -> Vec<MeterInterval> {
(0..n)
.map(|i| {
let from = start + Duration::hours(i);
MeterInterval {
from,
to: from + Duration::hours(1),
value: dec!(1),
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
#[test]
fn gas_days_are_cut_at_0600_local() {
let start = calendar::gas_day_start_utc(date!(2026 - 01 - 15));
let buckets = resample(&hourly(start, 48), &ResampleConfig::to_gas_daily());
assert_eq!(buckets.len(), 2, "two gas days, not three");
assert_eq!(buckets[0].from, datetime!(2026-01-15 5:00 UTC));
assert_eq!(buckets[0].to, datetime!(2026-01-16 5:00 UTC));
assert_eq!(buckets[0].total, dec!(24));
assert_eq!(buckets[0].expected_count, Some(24));
assert_eq!(buckets[0].is_complete(), Some(true));
let calendar_days = resample(
&hourly(start, 48),
&ResampleConfig::new(IntervalResolution::Hour, IntervalResolution::Day),
);
assert_eq!(calendar_days.len(), 3);
assert!(calendar_days[0].has_missing_data(), "18:00–24:00 only");
}
#[test]
fn the_long_gas_day_is_saturdays() {
let start = calendar::gas_day_start_utc(date!(2026 - 10 - 24));
let buckets = resample(&hourly(start, 49), &ResampleConfig::to_gas_daily());
assert_eq!(
buckets[0].from,
calendar::gas_day_start_utc(date!(2026 - 10 - 24))
);
assert_eq!(
buckets[0].expected_count,
Some(25),
"Saturday's Gastag holds the extra hour"
);
assert_eq!(buckets[0].interval_count, 25);
assert_eq!(buckets[0].is_complete(), Some(true));
assert_eq!(buckets[1].expected_count, Some(24), "Sunday's is ordinary");
}
#[test]
fn gas_months_are_whole_gas_days() {
let cfg = ResampleConfig::new(IntervalResolution::Hour, IntervalResolution::Month)
.on(DayBoundary::Gastag);
let start = calendar::gas_day_start_utc(date!(2026 - 01 - 31));
let buckets = resample(&hourly(start, 48), &cfg);
assert_eq!(buckets.len(), 2, "the month ends at 06:00 on 1 February");
assert_eq!(
buckets[0].to,
calendar::gas_day_start_utc(date!(2026 - 02 - 01))
);
assert_eq!(
buckets[1].from,
calendar::gas_day_start_utc(date!(2026 - 02 - 01))
);
}
}