use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::ids::obis::ObisCode;
use crate::precision::{ALLOCATION_DP, ALLOCATION_STRATEGY};
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::series::{Series, SeriesError};
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct MeterSample {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub at: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub reading: Decimal,
}
impl MeterSample {
#[must_use]
pub const fn new(at: OffsetDateTime, reading: Decimal) -> Self {
Self { at, reading }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionSplitConfig {
pub resolution: Resolution,
pub day_boundary: DayBoundary,
pub obis_code: Option<ObisCode>,
pub metered_quality: QualityFlag,
pub prorated_quality: QualityFlag,
}
impl SessionSplitConfig {
#[must_use]
pub const fn quarter_hourly() -> Self {
Self {
resolution: Resolution::QUARTER_HOUR,
day_boundary: DayBoundary::Strom,
obis_code: None,
metered_quality: QualityFlag::Measured,
prorated_quality: QualityFlag::Estimated,
}
}
#[must_use]
pub const fn at(mut self, resolution: Resolution) -> Self {
self.resolution = resolution;
self
}
#[must_use]
pub const fn on(mut self, boundary: DayBoundary) -> Self {
self.day_boundary = boundary;
self
}
#[must_use]
pub const fn with_obis(mut self, code: ObisCode) -> Self {
self.obis_code = Some(code);
self
}
#[must_use]
pub const fn prorated_as(mut self, flag: QualityFlag) -> Self {
self.prorated_quality = flag;
self
}
#[must_use]
pub const fn metered_as(mut self, flag: QualityFlag) -> Self {
self.metered_quality = flag;
self
}
}
impl Default for SessionSplitConfig {
fn default() -> Self {
Self::quarter_hourly()
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
#[non_exhaustive]
pub enum SessionError {
#[error("a session needs readings at two instants at least")]
TooFewSamples,
#[error("register reading at {at} is below the previous one")]
SamplesNotMonotonic {
at: OffsetDateTime,
},
#[error("two different readings at {at}")]
ConflictingSamples {
at: OffsetDateTime,
},
#[error("the slot at {at} lies outside the supported calendar years")]
OutOfRange {
at: OffsetDateTime,
},
#[error("series {index} is not on the configured grid and channel")]
GridMismatch {
index: usize,
},
#[error(transparent)]
Series(#[from] SeriesError),
}
struct Segment {
start: OffsetDateTime,
end: OffsetDateTime,
energy: Decimal,
cum_before: Decimal,
}
pub fn split_session(
samples: &[MeterSample],
config: &SessionSplitConfig,
) -> Result<Series, SessionError> {
let segments = build_segments(samples)?;
let (Some(first), Some(last)) = (segments.first(), segments.last()) else {
return Err(SessionError::TooFewSamples);
};
let (from, to) = (first.start, last.end);
let energy = last.cum_before + last.energy;
let bucket = |at| {
config
.day_boundary
.bucket(at, config.resolution)
.map(|p| p.range())
.ok_or(SessionError::OutOfRange { at })
};
let mut out = Vec::new();
let mut slot_start = bucket(from)?.0;
let mut cursor = 0usize;
let mut opening = Decimal::ZERO;
while slot_start < to {
let (bucket_from, bucket_to) = bucket(slot_start)?;
if bucket_to <= bucket_from {
return Err(SessionError::OutOfRange { at: bucket_from });
}
let covered_from = bucket_from.max(from);
let covered_to = bucket_to.min(to);
let closing = cumulative(&segments, cursor, from, to, energy, covered_to);
let exact = segments
.get(cursor..)
.unwrap_or_default()
.iter()
.take_while(|s| s.start < covered_to)
.all(|s| s.start >= covered_from && s.end <= covered_to);
while cursor + 1 < segments.len()
&& segments.get(cursor).is_some_and(|s| s.end <= covered_to)
{
cursor += 1;
}
let quality = if exact {
config.metered_quality
} else {
config.prorated_quality
};
out.push(
MeterInterval::build(
bucket_from,
bucket_to,
closing - opening,
quality,
config.obis_code,
)
.map_err(|_| SessionError::OutOfRange { at: bucket_from })?,
);
opening = closing;
slot_start = bucket_to;
}
Ok(Series::new(config.resolution, config.day_boundary, out)?)
}
fn build_segments(samples: &[MeterSample]) -> Result<Vec<Segment>, SessionError> {
let mut sorted: Vec<MeterSample> = samples.to_vec();
sorted.sort_by_key(|s| s.at);
let mut distinct: Vec<MeterSample> = Vec::with_capacity(sorted.len());
for s in sorted {
match distinct.last() {
Some(prev) if prev.at == s.at && prev.reading != s.reading => {
return Err(SessionError::ConflictingSamples { at: s.at });
}
Some(prev) if prev.at == s.at => {}
Some(prev) if s.reading < prev.reading => {
return Err(SessionError::SamplesNotMonotonic { at: s.at });
}
_ => distinct.push(s),
}
}
let base = distinct.first().map_or(Decimal::ZERO, |s| s.reading);
Ok(distinct
.windows(2)
.filter_map(|pair| match pair {
[a, b] => Some(Segment {
start: a.at,
end: b.at,
energy: b.reading - a.reading,
cum_before: a.reading - base,
}),
_ => None,
})
.collect())
}
fn cut(x: Decimal) -> Decimal {
x.round_dp_with_strategy(ALLOCATION_DP, ALLOCATION_STRATEGY)
}
fn cumulative(
segments: &[Segment],
hint: usize,
from: OffsetDateTime,
to: OffsetDateTime,
energy: Decimal,
t: OffsetDateTime,
) -> Decimal {
if t <= from {
return Decimal::ZERO;
}
if t >= to {
return energy;
}
let found = segments
.get(hint..)
.unwrap_or_default()
.iter()
.find(|s| s.start < t && t <= s.end)
.or_else(|| segments.iter().find(|s| s.start < t && t <= s.end));
let Some(seg) = found else {
return segments
.last()
.map_or(Decimal::ZERO, |s| cut(s.cum_before + s.energy));
};
let span = (seg.end - seg.start).whole_seconds();
if span <= 0 {
return cut(seg.cum_before + seg.energy);
}
let elapsed = (t - seg.start).whole_seconds();
cut(seg.cum_before + seg.energy * Decimal::from(elapsed) / Decimal::from(span))
}
pub fn merge_sessions(
config: &SessionSplitConfig,
series: &[Series],
) -> Result<Series, SessionError> {
let mut slots: BTreeMap<OffsetDateTime, (OffsetDateTime, Decimal, QualityFlag)> =
BTreeMap::new();
for (index, one) in series.iter().enumerate() {
if one.resolution() != config.resolution
|| one.boundary() != config.day_boundary
|| (!one.is_empty() && one.channel() != config.obis_code)
{
return Err(SessionError::GridMismatch { index });
}
for iv in one {
let entry =
slots
.entry(iv.from())
.or_insert((iv.to(), Decimal::ZERO, QualityFlag::Measured));
entry.1 += iv.value();
entry.2 = entry.2.worse_of(iv.quality());
}
}
let intervals = slots
.into_iter()
.map(|(from, (to, value, quality))| {
MeterInterval::build(from, to, value, quality, config.obis_code)
.map_err(|_| SessionError::OutOfRange { at: from })
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Series::new(
config.resolution,
config.day_boundary,
intervals,
)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::Direction;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn cfg() -> SessionSplitConfig {
SessionSplitConfig::quarter_hourly()
}
fn s(at: OffsetDateTime, reading: Decimal) -> MeterSample {
MeterSample::new(at, reading)
}
fn values(series: &Series) -> Vec<Decimal> {
series.iter().map(MeterInterval::value).collect()
}
#[test]
fn a_span_inside_one_slot_is_that_slot_and_measured() {
let out = split_session(
&[
s(datetime!(2026-06-01 12:02 UTC), dec!(10)),
s(datetime!(2026-06-01 12:09 UTC), dec!(13.75)),
],
&cfg(),
)
.unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out.as_slice()[0].from(), datetime!(2026-06-01 12:00 UTC));
assert_eq!(out.as_slice()[0].value(), dec!(3.75));
assert_eq!(out.as_slice()[0].quality(), QualityFlag::Measured);
}
#[test]
fn a_segment_crossing_a_boundary_is_estimated_on_both_sides() {
let out = split_session(
&[
s(datetime!(2026-06-01 12:10 UTC), dec!(0)),
s(datetime!(2026-06-01 12:20 UTC), dec!(2)),
],
&cfg(),
)
.unwrap();
assert_eq!(values(&out), vec![dec!(1), dec!(1)]);
assert!(out.iter().all(|iv| iv.quality() == QualityFlag::Estimated));
}
#[test]
fn a_still_register_gives_zeros_not_gaps() {
let t = datetime!(2026-06-01 12:00 UTC);
let out = split_session(
&[
s(t, dec!(0)),
s(t + Duration::minutes(15), dec!(4)),
s(t + Duration::minutes(45), dec!(4)),
s(t + Duration::minutes(60), dec!(6)),
],
&cfg(),
)
.unwrap();
assert_eq!(values(&out), vec![dec!(4), dec!(0), dec!(0), dec!(2)]);
let flags: Vec<QualityFlag> = out.iter().map(MeterInterval::quality).collect();
assert_eq!(flags[0], QualityFlag::Measured);
assert_eq!(flags[1], QualityFlag::Estimated);
}
#[test]
fn a_repeating_quotient_still_conserves_the_total() {
let t = datetime!(2026-06-01 12:00 UTC);
let out = split_session(
&[s(t, dec!(0)), s(t + Duration::minutes(45), dec!(1))],
&cfg(),
)
.unwrap();
assert_eq!(out.len(), 3);
assert_eq!(out.billable_total(), Some(dec!(1)));
}
#[test]
fn the_autumn_long_day_gets_a_hundred_slots() {
let day = DayBoundary::Strom.day(date!(2026 - 10 - 25)).unwrap();
let out =
split_session(&[s(day.start(), dec!(0)), s(day.end(), dec!(25))], &cfg()).unwrap();
assert_eq!(out.len(), 100);
assert_eq!(out.billable_total(), Some(dec!(25)));
}
#[test]
fn a_gastag_grid_cuts_at_six() {
let out = split_session(
&[
s(datetime!(2026-01-05 11:00 UTC), dec!(0)),
s(datetime!(2026-01-06 11:00 UTC), dec!(24)),
],
&cfg().at(Resolution::Day).on(DayBoundary::Gas),
)
.unwrap();
assert_eq!(out.len(), 2);
assert_eq!(
out.as_slice()[1].from(),
DayBoundary::Gas.day(date!(2026 - 01 - 06)).unwrap().start()
);
assert_eq!(out.billable_total(), Some(dec!(24)));
}
#[test]
fn the_obis_channel_is_stamped_and_gives_the_direction() {
let t = datetime!(2026-06-01 12:00 UTC);
let out = split_session(
&[s(t, dec!(0)), s(t + Duration::minutes(15), dec!(1))],
&cfg().with_obis(ObisCode::STROM_EINSPEISUNG_LASTGANG),
)
.unwrap();
assert_eq!(out.as_slice()[0].direction(), Some(Direction::Export));
}
#[test]
fn contradictory_readings_are_refused() {
let t = datetime!(2026-06-01 12:00 UTC);
assert_eq!(
split_session(&[s(t, dec!(1))], &cfg()),
Err(SessionError::TooFewSamples)
);
assert_eq!(
split_session(&[s(t, dec!(1)), s(t, dec!(1))], &cfg()),
Err(SessionError::TooFewSamples)
);
let later = t + Duration::minutes(5);
assert_eq!(
split_session(&[s(t, dec!(5)), s(later, dec!(4))], &cfg()),
Err(SessionError::SamplesNotMonotonic { at: later })
);
let a = [s(t, dec!(0)), s(later, dec!(1)), s(later, dec!(2))];
let mut b = a;
b.swap(1, 2);
assert_eq!(
split_session(&a, &cfg()),
Err(SessionError::ConflictingSamples { at: later })
);
assert_eq!(
split_session(&b, &cfg()),
Err(SessionError::ConflictingSamples { at: later })
);
}
#[test]
fn merging_keeps_channels_apart_and_takes_the_worst_quality() {
let t = datetime!(2026-06-01 12:00 UTC);
let one = |q| {
split_session(
&[s(t, dec!(0)), s(t + Duration::minutes(15), dec!(2))],
&cfg().metered_as(q),
)
.unwrap()
};
let merged = merge_sessions(
&cfg(),
&[one(QualityFlag::Measured), one(QualityFlag::Estimated)],
)
.unwrap();
assert_eq!(values(&merged), vec![dec!(4)]);
assert_eq!(merged.as_slice()[0].quality(), QualityFlag::Estimated);
let export = split_session(
&[s(t, dec!(0)), s(t + Duration::minutes(15), dec!(1))],
&cfg().with_obis(ObisCode::STROM_EINSPEISUNG_LASTGANG),
)
.unwrap();
assert_eq!(
merge_sessions(&cfg(), &[one(QualityFlag::Measured), export]),
Err(SessionError::GridMismatch { index: 1 })
);
assert!(merge_sessions(&cfg(), &[]).unwrap().is_empty());
}
#[test]
fn a_minute_log_is_measured_in_every_whole_slot() {
let day = DayBoundary::Strom.day(date!(2026 - 06 - 15)).unwrap();
let samples: Vec<MeterSample> = (0..=1440)
.map(|m| s(day.start() + Duration::minutes(m), Decimal::from(m)))
.collect();
let out = split_session(&samples, &cfg()).unwrap();
assert_eq!(out.len(), 96);
assert!(
out.iter()
.all(|iv| iv.value() == dec!(15) && iv.quality() == QualityFlag::Measured)
);
}
}