use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::ids::obis::ObisCode;
use crate::series::interval::{MeterInterval, Unit};
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SeriesError {
#[error("two intervals start at {from}")]
Duplicate {
from: OffsetDateTime,
},
#[error("the interval starting {later} overlaps the one starting {earlier}")]
Overlap {
earlier: OffsetDateTime,
later: OffsetDateTime,
},
#[error(
"interval [{from}, {to}) is not a bucket of the series' grid (expected [{expected_from}, {expected_to}))"
)]
Misaligned {
from: OffsetDateTime,
to: OffsetDateTime,
expected_from: OffsetDateTime,
expected_to: OffsetDateTime,
},
#[error("interval starting {from} lies outside the supported calendar years")]
OutOfRange {
from: OffsetDateTime,
},
#[error("the interval starting {from} is on a different channel")]
MixedChannels {
from: OffsetDateTime,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(try_from = "RawSeries"))]
pub struct Series {
resolution: Resolution,
boundary: DayBoundary,
intervals: Vec<MeterInterval>,
}
#[cfg(feature = "serde")]
#[derive(Deserialize)]
struct RawSeries {
resolution: Resolution,
boundary: DayBoundary,
intervals: Vec<MeterInterval>,
}
#[cfg(feature = "serde")]
impl TryFrom<RawSeries> for Series {
type Error = SeriesError;
fn try_from(raw: RawSeries) -> Result<Self, Self::Error> {
Self::new(raw.resolution, raw.boundary, raw.intervals)
}
}
impl Series {
pub fn new(
resolution: Resolution,
boundary: DayBoundary,
mut intervals: Vec<MeterInterval>,
) -> Result<Self, SeriesError> {
intervals.sort_by_key(MeterInterval::from);
let channel = intervals.first().and_then(MeterInterval::obis);
let mut previous: Option<&MeterInterval> = None;
for iv in &intervals {
if iv.obis() != channel {
return Err(SeriesError::MixedChannels { from: iv.from() });
}
if let Some(prev) = previous {
if prev.from() == iv.from() {
return Err(SeriesError::Duplicate { from: iv.from() });
}
if prev.to() > iv.from() {
return Err(SeriesError::Overlap {
earlier: prev.from(),
later: iv.from(),
});
}
}
let bucket = boundary
.bucket(iv.from(), resolution)
.ok_or(SeriesError::OutOfRange { from: iv.from() })?;
if bucket.range() != (iv.from(), iv.to()) {
return Err(SeriesError::Misaligned {
from: iv.from(),
to: iv.to(),
expected_from: bucket.start(),
expected_to: bucket.end(),
});
}
previous = Some(iv);
}
Ok(Self {
resolution,
boundary,
intervals,
})
}
pub fn iter(&self) -> std::slice::Iter<'_, MeterInterval> {
self.intervals.iter()
}
#[must_use]
pub fn as_slice(&self) -> &[MeterInterval] {
&self.intervals
}
#[must_use]
pub fn into_intervals(self) -> Vec<MeterInterval> {
self.intervals
}
#[must_use]
pub fn len(&self) -> usize {
self.intervals.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.intervals.is_empty()
}
#[must_use]
pub const fn resolution(&self) -> Resolution {
self.resolution
}
#[must_use]
pub const fn boundary(&self) -> DayBoundary {
self.boundary
}
#[must_use]
pub fn channel(&self) -> Option<ObisCode> {
self.intervals.first().and_then(MeterInterval::obis)
}
#[must_use]
pub fn unit(&self) -> Option<Unit> {
self.channel()?.unit()
}
#[must_use]
pub fn get(&self, from: OffsetDateTime) -> Option<&MeterInterval> {
self.intervals
.binary_search_by_key(&from, MeterInterval::from)
.ok()
.and_then(|i| self.intervals.get(i))
}
#[must_use]
pub fn billable_total(&self) -> Option<Decimal> {
self.intervals
.iter()
.filter(|iv| iv.quality().is_billable())
.try_fold(Decimal::ZERO, |sum, iv| sum.checked_add(iv.value()))
}
#[must_use]
pub fn span(&self) -> Option<(OffsetDateTime, OffsetDateTime)> {
Some((self.intervals.first()?.from(), self.intervals.last()?.to()))
}
}
impl<'a> IntoIterator for &'a Series {
type Item = &'a MeterInterval;
type IntoIter = std::slice::Iter<'a, MeterInterval>;
fn into_iter(self) -> Self::IntoIter {
self.intervals.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::QualityFlag;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn qh(from: OffsetDateTime) -> MeterInterval {
MeterInterval::quarter_hour(from, dec!(1), QualityFlag::Measured).unwrap()
}
fn series(ivs: Vec<MeterInterval>) -> Result<Series, SeriesError> {
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs)
}
#[test]
fn rejects_an_overlap_naming_both_intervals() {
let t = datetime!(2026-06-01 0:00 UTC);
let inside = MeterInterval::new(
t + Duration::minutes(5),
t + Duration::minutes(20),
dec!(1),
QualityFlag::Measured,
)
.unwrap();
assert_eq!(
series(vec![qh(t), inside]),
Err(SeriesError::Overlap {
earlier: t,
later: t + Duration::minutes(5),
})
);
}
#[test]
fn rejects_an_interval_outside_the_supported_years() {
let t = datetime!(9999-06-01 0:00 UTC);
assert_eq!(
series(vec![qh(t)]),
Err(SeriesError::OutOfRange { from: t })
);
}
#[test]
fn sorts_and_allows_gaps() {
let t = datetime!(2026-06-01 0:00 UTC);
let s = series(vec![qh(t + Duration::hours(1)), qh(t)]).unwrap();
assert_eq!(s.as_slice()[0].from(), t);
assert_eq!(s.len(), 2);
assert_eq!(s.span(), Some((t, t + Duration::minutes(75))));
}
#[test]
fn rejects_duplicates_overlaps_and_misalignment_with_the_instant() {
let t = datetime!(2026-06-01 0:00 UTC);
assert_eq!(
series(vec![qh(t), qh(t)]),
Err(SeriesError::Duplicate { from: t })
);
let odd = qh(t + Duration::minutes(5));
assert!(matches!(
series(vec![odd]),
Err(SeriesError::Misaligned { from, .. }) if from == t + Duration::minutes(5)
));
let hour = MeterInterval::hour(t, dec!(1), QualityFlag::Measured).unwrap();
assert!(matches!(
series(vec![hour]),
Err(SeriesError::Misaligned { .. })
));
let long = MeterInterval::new(t, t + Duration::minutes(30), dec!(1), QualityFlag::Measured)
.unwrap();
let inner = qh(t + Duration::minutes(15));
assert!(Series::new(Resolution::HALF_HOUR, DayBoundary::Strom, vec![long, inner]).is_err());
}
#[test]
fn rejects_mixed_channels() {
let t = datetime!(2026-06-01 0:00 UTC);
let a = qh(t).with_obis(ObisCode::STROM_BEZUG_LASTGANG);
let b = qh(t + Duration::minutes(15)).with_obis(ObisCode::STROM_EINSPEISUNG_LASTGANG);
assert_eq!(
series(vec![a.clone(), b]),
Err(SeriesError::MixedChannels {
from: t + Duration::minutes(15)
})
);
let s = series(vec![a]).unwrap();
assert_eq!(s.channel(), Some(ObisCode::STROM_BEZUG_LASTGANG));
assert_eq!(s.unit(), Some(Unit::KiloWattHour));
}
#[test]
fn a_long_day_is_one_daily_interval() {
let day = DayBoundary::Strom.day(date!(2026 - 10 - 25)).unwrap();
let iv =
MeterInterval::new(day.start(), day.end(), dec!(24), QualityFlag::Measured).unwrap();
let s = Series::new(Resolution::Day, DayBoundary::Strom, vec![iv]).unwrap();
assert_eq!(s.len(), 1);
assert!(Series::new(Resolution::Day, DayBoundary::Gas, s.into_intervals()).is_err());
}
#[test]
fn billable_total_excludes_faulty_values() {
let t = datetime!(2026-06-01 0:00 UTC);
let s = series(vec![
qh(t),
qh(t + Duration::minutes(15)).with_quality(QualityFlag::Faulty),
])
.unwrap();
assert_eq!(s.billable_total(), Some(dec!(1)));
assert_eq!(
s.get(t + Duration::minutes(15)).map(MeterInterval::quality),
Some(QualityFlag::Faulty)
);
assert!(s.get(t + Duration::minutes(30)).is_none());
}
}