use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::series::Series;
use crate::series::interval::QualityFlag;
use crate::time::resolution::Resolution;
pub mod mispel;
pub mod negative_prices;
pub mod zeitgleichheit;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum EegError {
#[error("series {series} has resolution {found}, not PT15M")]
Resolution {
series: &'static str,
found: Resolution,
},
#[error("series {series} is not on the grid of the first series at {at}")]
Misaligned {
series: &'static str,
at: OffsetDateTime,
},
#[error("series {series} holds a non-billable value at {at}")]
NotBillable {
series: &'static str,
at: OffsetDateTime,
},
#[error("series {series} holds a negative value at {at}")]
Negative {
series: &'static str,
at: OffsetDateTime,
},
#[error("no interval to evaluate")]
Empty,
#[error("the interval starting {at} lies outside the evaluated period")]
OutsidePeriod {
at: OffsetDateTime,
},
#[error("{formula} divides by a zero or negative {what}")]
ZeroWeight {
formula: &'static str,
what: &'static str,
},
#[error("{at} is not the start of a quarter-hour")]
NotQuarterHour {
at: OffsetDateTime,
},
#[error("series {series} has no value for the quarter-hour starting {at}")]
Missing {
series: &'static str,
at: OffsetDateTime,
},
#[error("the date range is outside the supported calendar")]
Calendar,
#[error("the inputs do not agree: {what}")]
Inconsistent {
what: &'static str,
},
#[error("arithmetic overflow")]
Overflow,
}
pub(crate) fn coincident(a: Decimal, b: Decimal) -> Decimal {
a.min(b)
}
#[derive(Debug, Clone)]
pub(crate) struct Row {
pub(crate) from: OffsetDateTime,
pub(crate) to: OffsetDateTime,
pub(crate) values: Vec<Decimal>,
pub(crate) quality: QualityFlag,
}
pub(crate) fn align(inputs: &[(&'static str, &Series)]) -> Result<Vec<Row>, EegError> {
let Some(&(_, first)) = inputs.first() else {
return Err(EegError::Empty);
};
for &(name, s) in inputs {
if s.resolution() != Resolution::QUARTER_HOUR {
return Err(EegError::Resolution {
series: name,
found: s.resolution(),
});
}
}
let mut rows = Vec::with_capacity(first.len());
for (i, head) in first.iter().enumerate() {
let mut values = Vec::with_capacity(inputs.len());
let mut quality = QualityFlag::Measured;
for &(name, s) in inputs {
let Some(iv) = s.as_slice().get(i) else {
return Err(EegError::Misaligned {
series: name,
at: head.from(),
});
};
if iv.from() != head.from() {
return Err(EegError::Misaligned {
series: name,
at: iv.from().min(head.from()),
});
}
if !iv.quality().is_billable() {
return Err(EegError::NotBillable {
series: name,
at: iv.from(),
});
}
if iv.value() < Decimal::ZERO {
return Err(EegError::Negative {
series: name,
at: iv.from(),
});
}
quality = quality.worse_of(iv.quality());
values.push(iv.value());
}
rows.push(Row {
from: head.from(),
to: head.to(),
values,
quality,
});
}
for &(name, s) in inputs {
if let Some(extra) = s.as_slice().get(first.len()) {
return Err(EegError::Misaligned {
series: name,
at: extra.from(),
});
}
}
Ok(rows)
}
pub(crate) fn sum(values: impl IntoIterator<Item = Decimal>) -> Result<Decimal, EegError> {
values
.into_iter()
.try_fold(Decimal::ZERO, |acc, v| acc.checked_add(v))
.ok_or(EegError::Overflow)
}
pub(crate) fn share(a: Decimal, b: Decimal, c: Decimal) -> Result<Option<Decimal>, EegError> {
if c.is_zero() {
return Ok(None);
}
a.checked_mul(b)
.ok_or(EegError::Overflow)?
.checked_div(c)
.map(Some)
.ok_or(EegError::Overflow)
}
pub(crate) fn sub(a: Decimal, b: Decimal) -> Result<Decimal, EegError> {
a.checked_sub(b).ok_or(EegError::Overflow)
}
pub(crate) fn add(a: Decimal, b: Decimal) -> Result<Decimal, EegError> {
a.checked_add(b).ok_or(EegError::Overflow)
}
pub(crate) fn mul(a: Decimal, b: Decimal) -> Result<Decimal, EegError> {
a.checked_mul(b).ok_or(EegError::Overflow)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::MeterInterval;
use crate::time::calendar::DayBoundary;
use rust_decimal::dec;
use time::Duration;
use time::macros::datetime;
fn qh(values: &[(i64, Decimal)]) -> Series {
let t = datetime!(2026-06-01 0:00 UTC);
let ivs = values
.iter()
.map(|&(i, v)| {
MeterInterval::quarter_hour(t + Duration::minutes(15 * i), v, QualityFlag::Measured)
.unwrap()
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn alignment_refuses_a_parting_grid_with_the_instant() {
let a = qh(&[(0, dec!(1)), (1, dec!(1))]);
let b = qh(&[(0, dec!(1)), (2, dec!(1))]);
assert_eq!(
align(&[("a", &a), ("b", &b)]).unwrap_err(),
EegError::Misaligned {
series: "b",
at: datetime!(2026-06-01 0:15 UTC)
}
);
let short = qh(&[(0, dec!(1))]);
assert!(matches!(
align(&[("a", &a), ("short", &short)]),
Err(EegError::Misaligned {
series: "short",
..
})
));
assert!(matches!(
align(&[("short", &short), ("a", &a)]),
Err(EegError::Misaligned { series: "a", .. })
));
let neg = qh(&[(0, dec!(-1)), (1, dec!(1))]);
assert!(matches!(
align(&[("a", &a), ("neg", &neg)]),
Err(EegError::Negative { series: "neg", .. })
));
}
#[test]
fn alignment_refuses_other_resolutions_and_faulty_values() {
let t = datetime!(2026-06-01 0:00 UTC);
let hour = Series::new(
Resolution::Hour,
DayBoundary::Strom,
vec![MeterInterval::hour(t, dec!(1), QualityFlag::Measured).unwrap()],
)
.unwrap();
assert!(matches!(
align(&[("h", &hour)]),
Err(EegError::Resolution { series: "h", .. })
));
let faulty = Series::new(
Resolution::QUARTER_HOUR,
DayBoundary::Strom,
vec![MeterInterval::quarter_hour(t, dec!(1), QualityFlag::Faulty).unwrap()],
)
.unwrap();
assert_eq!(
align(&[("f", &faulty)]).unwrap_err(),
EegError::NotBillable { series: "f", at: t }
);
}
}