use rust_decimal::Decimal;
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::series::{Series, SeriesError};
use crate::time::resolution::Resolution;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ResampleError {
#[error("{from} does not nest into {to}")]
NotCoarser {
from: Resolution,
to: Resolution,
},
#[error("a bucket total overflows")]
Overflow,
#[error(transparent)]
Series(#[from] SeriesError),
}
const fn nests(from: Resolution, to: Resolution) -> bool {
match (from.fixed_seconds(), to.fixed_seconds()) {
(Some(f), Some(t)) => t >= f && t % f == 0,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => matches!(
(from, to),
(
Resolution::Day,
Resolution::Day | Resolution::Month | Resolution::Year
) | (Resolution::Month, Resolution::Month | Resolution::Year)
| (Resolution::Year, Resolution::Year)
),
}
}
pub fn resample(series: &Series, target: Resolution) -> Result<Series, ResampleError> {
let (boundary, source) = (series.boundary(), series.resolution());
if !nests(source, target) {
return Err(ResampleError::NotCoarser {
from: source,
to: target,
});
}
let mut out: Vec<MeterInterval> = Vec::new();
let mut slots = series.iter().peekable();
while let Some(first) = slots.next() {
let Some(bucket) = boundary.bucket(first.from(), target) else {
continue;
};
let mut members = vec![first];
while let Some(next) = slots.next_if(|iv| iv.from() < bucket.end()) {
members.push(next);
}
let complete = members.first().map(|iv| iv.from()) == Some(bucket.start())
&& members.windows(2).all(|w| w[0].to() == w[1].from())
&& members.last().map(|iv| iv.to()) == Some(bucket.end());
if !complete {
continue;
}
let total = members
.iter()
.try_fold(Decimal::ZERO, |sum, iv| sum.checked_add(iv.value()))
.ok_or(ResampleError::Overflow)?;
let quality = QualityFlag::worst_of(members.iter().map(|iv| iv.quality()));
if let Ok(iv) = MeterInterval::build(
bucket.start(),
bucket.end(),
total,
quality,
series.channel(),
) {
out.push(iv);
}
}
Ok(Series::new(target, boundary, out)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::calendar::DayBoundary;
use rust_decimal::dec;
use time::macros::{date, datetime};
use time::{Duration, Month, OffsetDateTime};
fn qh(start: OffsetDateTime, n: i64, v: Decimal) -> Series {
let ivs = (0..n)
.map(|i| {
MeterInterval::quarter_hour(
start + Duration::minutes(15 * i),
v,
QualityFlag::Measured,
)
.unwrap()
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn four_quarters_make_an_hour() {
let s = qh(datetime!(2026-01-01 0:00 UTC), 8, dec!(2.5));
let h = resample(&s, Resolution::Hour).unwrap();
assert_eq!(h.len(), 2);
assert_eq!(h.as_slice()[0].value(), dec!(10));
assert_eq!(h.resolution(), Resolution::Hour);
}
#[test]
fn the_spring_day_is_92_quarter_hours_and_a_short_one_is_left_out() {
let day = DayBoundary::Strom.day(date!(2026 - 03 - 29)).unwrap();
let full = qh(day.start(), 92, dec!(1));
assert_eq!(
resample(&full, Resolution::Day).unwrap().as_slice()[0].value(),
dec!(92)
);
let short = qh(day.start(), 91, dec!(1));
assert!(resample(&short, Resolution::Day).unwrap().is_empty());
}
#[test]
fn a_gas_series_buckets_on_the_gastag() {
let gastag = DayBoundary::Gas.day(date!(2026 - 01 - 15)).unwrap();
let ivs = (0..24)
.map(|i| {
MeterInterval::hour(
gastag.start() + Duration::hours(i),
dec!(1),
QualityFlag::Measured,
)
.unwrap()
})
.collect();
let s = Series::new(Resolution::Hour, DayBoundary::Gas, ivs).unwrap();
let d = resample(&s, Resolution::Day).unwrap();
assert_eq!(d.as_slice()[0].from(), gastag.start());
assert_eq!(d.as_slice()[0].value(), dec!(24));
}
#[test]
fn days_nest_into_months_and_the_worst_quality_wins() {
let march = DayBoundary::Strom.month(2026, Month::March).unwrap();
let ivs: Vec<_> = (0..31)
.map(|d| {
let p = DayBoundary::Strom
.day(date!(2026 - 03 - 01) + Duration::days(d))
.unwrap();
let q = if d == 3 {
QualityFlag::Substituted
} else {
QualityFlag::Measured
};
MeterInterval::new(p.start(), p.end(), dec!(2), q).unwrap()
})
.collect();
let s = Series::new(Resolution::Day, DayBoundary::Strom, ivs).unwrap();
let m = resample(&s, Resolution::Month).unwrap();
assert_eq!(m.as_slice()[0].from(), march.start());
assert_eq!(m.as_slice()[0].value(), dec!(62));
assert_eq!(m.as_slice()[0].quality(), QualityFlag::Substituted);
}
#[test]
fn a_finer_or_non_nesting_target_is_refused() {
let s = qh(datetime!(2026-01-01 0:00 UTC), 4, dec!(1));
assert!(matches!(
resample(&s, Resolution::minutes(5).unwrap()),
Err(ResampleError::NotCoarser { .. })
));
let six = Series::new(Resolution::minutes(6).unwrap(), DayBoundary::Strom, vec![]).unwrap();
assert!(resample(&six, Resolution::minutes(4).unwrap()).is_err());
assert!(resample(&six, Resolution::minutes(10).unwrap()).is_err());
assert!(resample(&six, Resolution::minutes(12).unwrap()).is_ok());
}
}