use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::OffsetDateTime;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::allocation::allocation_share;
use crate::calendar::DayBoundary;
use crate::interval::{MeterInterval, QualityFlag};
use crate::obis::ObisCode;
use crate::resolution::IntervalResolution;
#[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,
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: IntervalResolution,
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: IntervalResolution::QuarterHour,
day_boundary: DayBoundary::Midnight,
obis_code: None,
metered_quality: QualityFlag::Measured,
prorated_quality: QualityFlag::Estimated,
}
}
#[must_use]
pub const fn at(mut self, resolution: IntervalResolution) -> 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("session span {from} → {to} is empty or reversed")]
EmptySpan {
from: OffsetDateTime,
to: OffsetDateTime,
},
#[error("session energy {energy} is negative")]
NegativeEnergy {
energy: Decimal,
},
#[error("sample at {at} falls outside the session span")]
SampleOutsideSpan {
at: OffsetDateTime,
},
#[error("register reading at {at} is below the previous one")]
SamplesNotMonotonic {
at: OffsetDateTime,
},
#[error(
"samples account for {sampled} of a {session} session, and the difference has nowhere to go"
)]
SampleTotalMismatch {
session: Decimal,
sampled: Decimal,
},
}
struct Segment {
start: OffsetDateTime,
end: OffsetDateTime,
energy: Decimal,
cum_before: Decimal,
exact: bool,
}
pub fn split_session(
from: OffsetDateTime,
to: OffsetDateTime,
energy: Decimal,
samples: &[MeterSample],
config: &SessionSplitConfig,
) -> Result<Vec<MeterInterval>, SessionError> {
if to <= from {
return Err(SessionError::EmptySpan { from, to });
}
if energy < Decimal::ZERO {
return Err(SessionError::NegativeEnergy { energy });
}
let segments = build_segments(from, to, energy, samples)?;
let mut out = Vec::new();
let mut slot_start = config.day_boundary.bucket_bounds(from, config.resolution).0;
let mut cursor = 0usize;
let mut carried = Decimal::ZERO;
while slot_start < to {
let (bucket_from, bucket_to) = config
.day_boundary
.bucket_bounds(slot_start, config.resolution);
if bucket_to <= bucket_from {
break;
}
let covered_from = bucket_from.max(from);
let covered_to = bucket_to.min(to);
let opening = if out.is_empty() {
cumulative(&segments, cursor, from, to, energy, covered_from)
} else {
carried
};
let closing = cumulative(&segments, cursor, from, to, energy, covered_to);
carried = closing;
let exact = segments[cursor..]
.iter()
.take_while(|s| s.start < covered_to)
.all(|s| s.exact && s.start >= covered_from && s.end <= covered_to);
while cursor + 1 < segments.len() && segments[cursor].end <= covered_to {
cursor += 1;
}
out.push(MeterInterval {
from: bucket_from,
to: bucket_to,
value: closing - opening,
quality: if exact {
config.metered_quality
} else {
config.prorated_quality
},
obis_code: config.obis_code,
});
slot_start = bucket_to;
}
Ok(out)
}
fn build_segments(
from: OffsetDateTime,
to: OffsetDateTime,
energy: Decimal,
samples: &[MeterSample],
) -> Result<Vec<Segment>, SessionError> {
for s in samples {
if s.at < from || s.at > to {
return Err(SessionError::SampleOutsideSpan { at: s.at });
}
}
let mut sorted: Vec<MeterSample> = samples.to_vec();
sorted.sort_by_key(|s| s.at);
sorted.dedup_by(|later, earlier| {
if later.at == earlier.at {
earlier.reading = later.reading;
true
} else {
false
}
});
for pair in sorted.windows(2) {
if pair[1].reading < pair[0].reading {
return Err(SessionError::SamplesNotMonotonic { at: pair[1].at });
}
}
let sampled = match (sorted.first(), sorted.last()) {
(Some(first), Some(last)) => last.reading - first.reading,
_ => Decimal::ZERO,
};
let remainder = energy - sampled;
if remainder < Decimal::ZERO {
return Err(SessionError::SampleTotalMismatch {
session: energy,
sampled,
});
}
let head = sorted.first().map_or(to, |s| s.at); let tail = sorted.last().map_or(to, |s| s.at);
let head_secs = (head - from).whole_seconds().max(0);
let tail_secs = (to - tail).whole_seconds().max(0);
let uncovered = head_secs + tail_secs;
if uncovered == 0 && !remainder.is_zero() {
return Err(SessionError::SampleTotalMismatch {
session: energy,
sampled,
});
}
let head_energy = if uncovered == 0 {
Decimal::ZERO
} else {
allocation_share(remainder * Decimal::from(head_secs) / Decimal::from(uncovered))
};
let tail_energy = remainder - head_energy;
let ends_are_exact = head_secs == 0 || tail_secs == 0;
let mut segments: Vec<Segment> = Vec::with_capacity(sorted.len() + 1);
let mut cum_before = Decimal::ZERO;
let mut push = |start: OffsetDateTime, end: OffsetDateTime, e: Decimal, exact: bool| {
if end > start {
segments.push(Segment {
start,
end,
energy: e,
cum_before,
exact,
});
cum_before += e;
}
};
if head_secs > 0 {
push(from, head, head_energy, ends_are_exact);
}
for pair in sorted.windows(2) {
push(
pair[0].at,
pair[1].at,
pair[1].reading - pair[0].reading,
true,
);
}
if tail_secs > 0 {
push(tail, to, tail_energy, ends_are_exact);
}
Ok(segments)
}
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 Some(seg) = segments[hint.min(segments.len())..]
.iter()
.find(|s| s.start < t && t <= s.end)
else {
return Decimal::ZERO;
};
let span = (seg.end - seg.start).whole_seconds();
if span <= 0 {
return allocation_share(seg.cum_before + seg.energy);
}
let elapsed = (t - seg.start).whole_seconds();
allocation_share(seg.cum_before + seg.energy * Decimal::from(elapsed) / Decimal::from(span))
}
#[must_use]
pub fn merge_sessions(series: &[Vec<MeterInterval>]) -> Vec<MeterInterval> {
let mut slots: BTreeMap<
(OffsetDateTime, OffsetDateTime, Option<ObisCode>),
(Decimal, QualityFlag),
> = BTreeMap::new();
for one in series {
for iv in one {
let entry = slots
.entry((iv.from, iv.to, iv.obis_code))
.or_insert((Decimal::ZERO, QualityFlag::Measured));
entry.0 += iv.value;
entry.1 = entry.1.worse_of(iv.quality);
}
}
slots
.into_iter()
.map(|((from, to, obis_code), (value, quality))| MeterInterval {
from,
to,
value,
quality,
obis_code,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calendar;
use rust_decimal::dec;
use time::Duration;
use time::macros::{date, datetime};
fn cfg() -> SessionSplitConfig {
SessionSplitConfig::quarter_hourly()
}
fn total(slots: &[MeterInterval]) -> Decimal {
slots.iter().map(|s| s.value).sum()
}
#[test]
fn a_session_inside_one_slot_stays_in_it() {
let slots = split_session(
datetime!(2026-06-01 12:02 UTC),
datetime!(2026-06-01 12:09 UTC),
dec!(3.75),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].from, datetime!(2026-06-01 12:00 UTC));
assert_eq!(slots[0].to, datetime!(2026-06-01 12:15 UTC));
assert_eq!(slots[0].value, dec!(3.75));
}
#[test]
fn a_span_wholly_inside_one_slot_is_measured() {
let slots = split_session(
datetime!(2026-06-01 12:02 UTC),
datetime!(2026-06-01 12:09 UTC),
dec!(3.75),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots[0].quality, QualityFlag::Measured);
}
#[test]
fn a_span_that_crosses_a_boundary_is_estimated_on_both_sides() {
let slots = split_session(
datetime!(2026-06-01 12:10 UTC),
datetime!(2026-06-01 12:20 UTC),
dec!(2),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 2);
assert!(slots.iter().all(|s| s.quality == QualityFlag::Estimated));
assert_eq!(slots[0].value, dec!(1));
assert_eq!(slots[1].value, dec!(1));
}
#[test]
fn samples_on_both_ends_make_every_whole_slot_measured() {
let samples = [
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(100)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(103)),
MeterSample::new(datetime!(2026-06-01 12:30 UTC), dec!(110)),
];
let slots = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(10),
&samples,
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 2);
assert_eq!(slots[0].value, dec!(3));
assert_eq!(slots[1].value, dec!(7));
assert!(slots.iter().all(|s| s.quality == QualityFlag::Measured));
assert_eq!(total(&slots), dec!(10));
}
#[test]
fn an_untouched_slot_inside_the_span_comes_back_as_a_zero() {
let samples = [
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(0)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(4)),
MeterSample::new(datetime!(2026-06-01 12:45 UTC), dec!(4)),
MeterSample::new(datetime!(2026-06-01 13:00 UTC), dec!(6)),
];
let slots = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 13:00 UTC),
dec!(6),
&samples,
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 4);
assert_eq!(
slots.iter().map(|s| s.value).collect::<Vec<_>>(),
vec![dec!(4), dec!(0), dec!(0), dec!(2)]
);
assert_eq!(total(&slots), dec!(6));
}
#[test]
fn a_repeating_quotient_still_conserves_the_total() {
let slots = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:45 UTC),
dec!(1),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 3);
assert_eq!(total(&slots), dec!(1));
}
#[test]
fn the_autumn_long_day_gets_a_hundred_slots() {
let day = date!(2026 - 10 - 25);
let slots = split_session(
calendar::day_start_utc(day),
calendar::day_end_utc(day),
dec!(100),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 100);
assert_eq!(total(&slots), dec!(100));
assert!(slots.iter().all(|s| s.value == dec!(1)));
}
#[test]
fn the_spring_short_day_gets_ninety_two_slots() {
let day = date!(2026 - 03 - 29);
let slots = split_session(
calendar::day_start_utc(day),
calendar::day_end_utc(day),
dec!(92),
&[],
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 92);
assert_eq!(total(&slots), dec!(92));
}
#[test]
fn a_gastag_grid_cuts_at_six() {
let slots = split_session(
datetime!(2026-01-05 11:00 UTC),
datetime!(2026-01-06 11:00 UTC),
dec!(24),
&[],
&cfg().at(IntervalResolution::Day).on(DayBoundary::Gastag),
)
.unwrap();
assert_eq!(slots.len(), 2);
assert_eq!(
slots[0].from,
calendar::gas_day_start_utc(date!(2026 - 01 - 05))
);
assert_eq!(
slots[1].from,
calendar::gas_day_start_utc(date!(2026 - 01 - 06))
);
assert_eq!(total(&slots), dec!(24));
}
#[test]
fn the_obis_channel_is_stamped_and_gives_the_direction() {
let slots = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(1),
&[],
&cfg().with_obis("1-0:2.8.0".parse().unwrap()),
)
.unwrap();
assert_eq!(
slots[0].direction(),
Some(crate::interval::Direction::Export)
);
}
#[test]
fn an_empty_span_is_an_error() {
let t = datetime!(2026-06-01 12:00 UTC);
assert_eq!(
split_session(t, t, dec!(1), &[], &cfg()),
Err(SessionError::EmptySpan { from: t, to: t })
);
}
#[test]
fn a_negative_total_is_an_error() {
assert!(matches!(
split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(-1),
&[],
&cfg(),
),
Err(SessionError::NegativeEnergy { .. })
));
}
#[test]
fn a_sample_outside_the_span_is_an_error() {
assert!(matches!(
split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(1),
&[MeterSample::new(datetime!(2026-06-01 13:00 UTC), dec!(1))],
&cfg(),
),
Err(SessionError::SampleOutsideSpan { .. })
));
}
#[test]
fn a_register_that_runs_backwards_is_an_error() {
assert!(matches!(
split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(1),
&[
MeterSample::new(datetime!(2026-06-01 12:05 UTC), dec!(9)),
MeterSample::new(datetime!(2026-06-01 12:10 UTC), dec!(8)),
],
&cfg(),
),
Err(SessionError::SamplesNotMonotonic { .. })
));
}
#[test]
fn samples_that_exceed_the_session_total_are_an_error() {
assert!(matches!(
split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(1),
&[
MeterSample::new(datetime!(2026-06-01 12:05 UTC), dec!(0)),
MeterSample::new(datetime!(2026-06-01 12:10 UTC), dec!(8)),
],
&cfg(),
),
Err(SessionError::SampleTotalMismatch { .. })
));
}
#[test]
fn a_discrepancy_with_no_uncovered_end_is_an_error() {
assert!(matches!(
split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(10),
&[
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(0)),
MeterSample::new(datetime!(2026-06-01 12:30 UTC), dec!(8)),
],
&cfg(),
),
Err(SessionError::SampleTotalMismatch { .. })
));
}
#[test]
fn samples_may_arrive_in_any_order() {
let mut samples = vec![
MeterSample::new(datetime!(2026-06-01 12:30 UTC), dec!(110)),
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(100)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(103)),
];
let forward = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(10),
&samples,
&cfg(),
)
.unwrap();
samples.reverse();
let backward = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(10),
&samples,
&cfg(),
)
.unwrap();
assert_eq!(forward, backward);
}
#[test]
fn two_readings_at_one_instant_do_not_divide_by_zero() {
let slots = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:30 UTC),
dec!(10),
&[
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(0)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(3)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(4)),
MeterSample::new(datetime!(2026-06-01 12:30 UTC), dec!(10)),
],
&cfg(),
)
.unwrap();
assert_eq!(slots[0].value, dec!(4));
assert_eq!(slots[1].value, dec!(6));
assert_eq!(total(&slots), dec!(10));
}
#[test]
fn no_slot_is_ever_negative() {
let slots = split_session(
datetime!(2026-06-01 12:07 UTC),
datetime!(2026-06-01 14:23 UTC),
dec!(37.771),
&[MeterSample::new(datetime!(2026-06-01 13:11 UTC), dec!(5.5))],
&cfg(),
)
.unwrap();
assert!(slots.iter().all(|s| s.value >= Decimal::ZERO));
assert_eq!(total(&slots), dec!(37.771));
}
#[test]
fn merging_keeps_import_and_export_apart() {
let cfg = SessionSplitConfig::quarter_hourly();
let charge = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(5),
&[],
&cfg.with_obis("1-0:1.8.0".parse().unwrap()),
)
.unwrap();
let discharge = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(2),
&[],
&cfg.with_obis("1-0:2.8.0".parse().unwrap()),
)
.unwrap();
let merged = merge_sessions(&[charge, discharge]);
assert_eq!(merged.len(), 2, "one slot, two registers");
let balance = crate::aggregation::sum_by_direction(&merged);
assert_eq!(balance.import, dec!(5));
assert_eq!(balance.export, dec!(2));
assert_eq!(balance.net(), dec!(3));
}
#[test]
fn merging_takes_the_worst_quality_and_does_not_depend_on_order() {
let cfg = SessionSplitConfig::quarter_hourly();
let measured = split_session(
datetime!(2026-06-01 12:00 UTC),
datetime!(2026-06-01 12:15 UTC),
dec!(3),
&[
MeterSample::new(datetime!(2026-06-01 12:00 UTC), dec!(0)),
MeterSample::new(datetime!(2026-06-01 12:15 UTC), dec!(3)),
],
&cfg,
)
.unwrap();
assert_eq!(measured[0].quality, QualityFlag::Measured);
let estimated = split_session(
datetime!(2026-06-01 12:05 UTC),
datetime!(2026-06-01 12:20 UTC),
dec!(3),
&[],
&cfg,
)
.unwrap();
let forward = merge_sessions(&[measured.clone(), estimated.clone()]);
let backward = merge_sessions(&[estimated, measured]);
assert_eq!(forward, backward);
assert!(forward.iter().all(|s| s.quality == QualityFlag::Estimated));
}
#[test]
fn merging_nothing_is_nothing() {
assert!(merge_sessions(&[]).is_empty());
assert!(merge_sessions(&[Vec::new(), Vec::new()]).is_empty());
}
#[test]
fn a_densely_sampled_day_still_conserves_and_stays_ordered() {
let day = date!(2026 - 06 - 15);
let start = calendar::day_start_utc(day);
let samples: Vec<MeterSample> = (1..1440)
.map(|m| MeterSample::new(start + Duration::minutes(m), Decimal::from(m)))
.collect();
let slots = split_session(
start,
calendar::day_end_utc(day),
dec!(1440),
&samples,
&cfg(),
)
.unwrap();
assert_eq!(slots.len(), 96);
assert_eq!(total(&slots), dec!(1440));
for pair in slots.windows(2) {
assert_eq!(pair[0].to, pair[1].from);
}
assert!(slots[1..95].iter().all(|s| s.value == dec!(15)));
assert!(
slots[1..95]
.iter()
.all(|s| s.quality == QualityFlag::Measured)
);
}
}