use std::collections::BTreeMap;
use rust_decimal::Decimal;
use time::{Duration, OffsetDateTime};
use crate::precision::{SUBSTITUTE_DP, SUBSTITUTE_STRATEGY};
use crate::series::interval::{MeterInterval, QualityFlag, Sparte};
use crate::series::reading::MeterReading;
use crate::series::{Series, SeriesError};
use crate::time::calendar::Period;
use crate::time::holiday::Bundesland;
use crate::vee::validation::{Report, Rule, Severity};
use crate::vee::{Data, is_actual, references, slots};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Method {
Interpolation,
Vergleichswert,
Hold,
Zero,
}
impl Method {
pub const ALL: [Self; 4] = [
Self::Interpolation,
Self::Vergleichswert,
Self::Hold,
Self::Zero,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Interpolation => "INTERPOLATION",
Self::Vergleichswert => "VERGLEICHSWERT",
Self::Hold => "HOLD",
Self::Zero => "ZERO",
}
}
#[must_use]
pub const fn market_code(self, sparte: Sparte) -> Option<&'static str> {
match (self, sparte) {
(Self::Interpolation, Sparte::Strom | Sparte::Gas) => Some("Z92"),
(Self::Vergleichswert, Sparte::Strom) => Some("ZJ2"),
(Self::Vergleichswert, Sparte::Gas) => Some("Z95"),
(Self::Hold, Sparte::Gas) => Some("Z93"),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubstitutionReason {
NoAccess,
CommunicationFailure,
GridOutage,
VoltageFailure,
DeviceExchange,
Calibration,
OutsideOperatingConditions,
MeteringEquipmentFault,
MeasurementUncertain,
FaultRegisterUsed,
ConversionIncomplete,
ClockAdjusted,
ImplausibleValue,
WrongTransformerRatio,
FaultyReading,
CalculationChanged,
MeteringPointRebuilt,
DataProcessingError,
MeteringEquipmentDefect,
TariffTimesChanged,
TariffSwitchDeviceDefect,
InsufficientPulseWeight,
MaintenanceCalibratedDevice,
DeviceReportsDisturbedValues,
MaintenanceConformantDevice,
ConsistencyCheckFailed,
StatedPerMeteringPoint,
RetrospectiveRequest,
}
impl SubstitutionReason {
pub const ALL: [Self; 28] = [
Self::NoAccess,
Self::CommunicationFailure,
Self::GridOutage,
Self::VoltageFailure,
Self::DeviceExchange,
Self::Calibration,
Self::OutsideOperatingConditions,
Self::MeteringEquipmentFault,
Self::MeasurementUncertain,
Self::FaultRegisterUsed,
Self::ConversionIncomplete,
Self::ClockAdjusted,
Self::ImplausibleValue,
Self::WrongTransformerRatio,
Self::FaultyReading,
Self::CalculationChanged,
Self::MeteringPointRebuilt,
Self::DataProcessingError,
Self::MeteringEquipmentDefect,
Self::TariffTimesChanged,
Self::TariffSwitchDeviceDefect,
Self::InsufficientPulseWeight,
Self::MaintenanceCalibratedDevice,
Self::DeviceReportsDisturbedValues,
Self::MaintenanceConformantDevice,
Self::ConsistencyCheckFailed,
Self::StatedPerMeteringPoint,
Self::RetrospectiveRequest,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NoAccess => "NO_ACCESS",
Self::CommunicationFailure => "COMMUNICATION_FAILURE",
Self::GridOutage => "GRID_OUTAGE",
Self::VoltageFailure => "VOLTAGE_FAILURE",
Self::DeviceExchange => "DEVICE_EXCHANGE",
Self::Calibration => "CALIBRATION",
Self::OutsideOperatingConditions => "OUTSIDE_OPERATING_CONDITIONS",
Self::MeteringEquipmentFault => "METERING_EQUIPMENT_FAULT",
Self::MeasurementUncertain => "MEASUREMENT_UNCERTAIN",
Self::FaultRegisterUsed => "FAULT_REGISTER_USED",
Self::ConversionIncomplete => "CONVERSION_INCOMPLETE",
Self::ClockAdjusted => "CLOCK_ADJUSTED",
Self::ImplausibleValue => "IMPLAUSIBLE_VALUE",
Self::WrongTransformerRatio => "WRONG_TRANSFORMER_RATIO",
Self::FaultyReading => "FAULTY_READING",
Self::CalculationChanged => "CALCULATION_CHANGED",
Self::MeteringPointRebuilt => "METERING_POINT_REBUILT",
Self::DataProcessingError => "DATA_PROCESSING_ERROR",
Self::MeteringEquipmentDefect => "METERING_EQUIPMENT_DEFECT",
Self::TariffTimesChanged => "TARIFF_TIMES_CHANGED",
Self::TariffSwitchDeviceDefect => "TARIFF_SWITCH_DEVICE_DEFECT",
Self::InsufficientPulseWeight => "INSUFFICIENT_PULSE_WEIGHT",
Self::MaintenanceCalibratedDevice => "MAINTENANCE_CALIBRATED_DEVICE",
Self::DeviceReportsDisturbedValues => "DEVICE_REPORTS_DISTURBED_VALUES",
Self::MaintenanceConformantDevice => "MAINTENANCE_CONFORMANT_DEVICE",
Self::ConsistencyCheckFailed => "CONSISTENCY_CHECK_FAILED",
Self::StatedPerMeteringPoint => "STATED_PER_METERING_POINT",
Self::RetrospectiveRequest => "RETROSPECTIVE_REQUEST",
}
}
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::NoAccess => "Z74",
Self::CommunicationFailure => "Z75",
Self::GridOutage => "Z76",
Self::VoltageFailure => "Z77",
Self::DeviceExchange => "Z78",
Self::Calibration => "Z79",
Self::OutsideOperatingConditions => "Z80",
Self::MeteringEquipmentFault => "Z81",
Self::MeasurementUncertain => "Z82",
Self::FaultRegisterUsed => "Z98",
Self::ConversionIncomplete => "Z99",
Self::ClockAdjusted => "ZA0",
Self::ImplausibleValue => "ZA1",
Self::WrongTransformerRatio => "ZA3",
Self::FaultyReading => "ZA4",
Self::CalculationChanged => "ZA5",
Self::MeteringPointRebuilt => "ZA6",
Self::DataProcessingError => "ZA7",
Self::MeteringEquipmentDefect => "ZB0",
Self::TariffTimesChanged => "ZB9",
Self::TariffSwitchDeviceDefect => "ZC2",
Self::InsufficientPulseWeight => "ZC4",
Self::MaintenanceCalibratedDevice => "ZR1",
Self::DeviceReportsDisturbedValues => "ZR2",
Self::MaintenanceConformantDevice => "ZR3",
Self::ConsistencyCheckFailed => "ZR4",
Self::StatedPerMeteringPoint => "ZS9",
Self::RetrospectiveRequest => "ZT8",
}
}
#[must_use]
pub fn from_code(code: &str) -> Option<Self> {
let upper = code.trim().to_uppercase();
Self::ALL.into_iter().find(|r| r.code() == upper)
}
#[must_use]
pub const fn description(self) -> &'static str {
match self {
Self::NoAccess => "kein Zugang",
Self::CommunicationFailure => "Kommunikationsstörung",
Self::GridOutage => "Netzausfall",
Self::VoltageFailure => "Spannungsausfall",
Self::DeviceExchange => "Gerätewechsel",
Self::Calibration => "Kalibrierung",
Self::OutsideOperatingConditions => "Gerät arbeitet außerhalb der Betriebsbedingungen",
Self::MeteringEquipmentFault => "Messeinrichtung gestört/defekt",
Self::MeasurementUncertain => "Unsicherheit Messung",
Self::FaultRegisterUsed => "Berücksichtigung Störmengenzählwerk",
Self::ConversionIncomplete => "Mengenumwertung unvollständig",
Self::ClockAdjusted => "Uhrzeit gestellt /Synchronisation",
Self::ImplausibleValue => "Messwert unplausibel",
Self::WrongTransformerRatio => "Falscher Wandlerfaktor",
Self::FaultyReading => "Fehlerhafte Ablesung",
Self::CalculationChanged => "Änderung der Berechnung",
Self::MeteringPointRebuilt => "Umbau der Messlokation",
Self::DataProcessingError => "Datenbearbeitungsfehler",
Self::MeteringEquipmentDefect => "Störung / Defekt Messeinrichtung",
Self::TariffTimesChanged => "Änderung Tarifschaltzeiten",
Self::TariffSwitchDeviceDefect => "Tarifschaltgerät defekt",
Self::InsufficientPulseWeight => "Impulswertigkeit nicht ausreichend",
Self::MaintenanceCalibratedDevice => "Wartungsarbeiten an geeichtem Messgerät",
Self::DeviceReportsDisturbedValues => "gestörte Werte",
Self::MaintenanceConformantDevice => {
"Wartungsarbeiten an eichrechtskonformen Messgeräten"
}
Self::ConsistencyCheckFailed => "Konsistenz- und Synchronprüfung",
Self::StatedPerMeteringPoint => {
"Grund der Ersatzwertbildung gemäß Angaben auf Ebene der Messlokation"
}
Self::RetrospectiveRequest => {
"Anforderung in die Vergangenheit, zum angeforderten Zeitpunkt liegt kein Wert vor."
}
}
}
#[must_use]
pub const fn applies_to(self, sparte: Sparte) -> bool {
match self {
Self::VoltageFailure | Self::Calibration | Self::TariffSwitchDeviceDefect => {
matches!(sparte, Sparte::Strom)
}
Self::FaultRegisterUsed
| Self::ConversionIncomplete
| Self::MaintenanceCalibratedDevice
| Self::DeviceReportsDisturbedValues
| Self::MaintenanceConformantDevice
| Self::ConsistencyCheckFailed => matches!(sparte, Sparte::Gas),
_ => true,
}
}
}
crate::ids::codes::string_codes! {
Method;
SubstitutionReason;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PolicyError {
#[error("a held value has no market code for {0}")]
HoldNotCoded(Sparte),
#[error("the anchor's end reading at {end} is not after its start at {start}")]
AnchorReversed {
start: OffsetDateTime,
end: OffsetDateTime,
},
#[error("the register decreased between {start} and {end}")]
AnchorDecreasing {
start: OffsetDateTime,
end: OffsetDateTime,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Policy {
sparte: Sparte,
land: Bundesland,
reason: SubstitutionReason,
short: Duration,
days: usize,
hold: bool,
outages: Vec<(OffsetDateTime, OffsetDateTime)>,
anchor: Option<(MeterReading, MeterReading)>,
history: Option<Series>,
}
impl Policy {
#[must_use]
pub const fn new(sparte: Sparte, land: Bundesland, reason: SubstitutionReason) -> Self {
Self {
sparte,
land,
reason,
short: Duration::hours(2),
days: 3,
hold: false,
outages: Vec::new(),
anchor: None,
history: None,
}
}
#[must_use]
pub const fn strom(land: Bundesland, reason: SubstitutionReason) -> Self {
Self::new(Sparte::Strom, land, reason)
}
#[must_use]
pub const fn gas(land: Bundesland, reason: SubstitutionReason) -> Self {
Self::new(Sparte::Gas, land, reason)
}
#[must_use]
pub const fn short(mut self, max_gap: Duration) -> Self {
self.short = max_gap;
self
}
#[must_use]
pub const fn days(mut self, n: usize) -> Self {
self.days = n;
self
}
pub fn hold(mut self) -> Result<Self, PolicyError> {
if self.sparte != Sparte::Gas {
return Err(PolicyError::HoldNotCoded(self.sparte));
}
self.hold = true;
Ok(self)
}
#[must_use]
pub fn outage(mut self, from: OffsetDateTime, to: OffsetDateTime) -> Self {
self.outages.push((from, to));
self
}
#[must_use]
pub fn history(mut self, prior: Series) -> Self {
self.history = Some(prior);
self
}
pub fn anchor(mut self, start: MeterReading, end: MeterReading) -> Result<Self, PolicyError> {
if end.at <= start.at {
return Err(PolicyError::AnchorReversed {
start: start.at,
end: end.at,
});
}
if end.value < start.value {
return Err(PolicyError::AnchorDecreasing {
start: start.at,
end: end.at,
});
}
self.anchor = Some((start, end));
Ok(self)
}
fn in_outage(&self, slot: &Period) -> bool {
self.outages
.iter()
.any(|(from, to)| *from <= slot.start() && slot.end() <= *to)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Substitute {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub value: Decimal,
pub method: Method,
pub code: Option<&'static str>,
pub reason: SubstitutionReason,
pub gap: Duration,
pub references: Vec<OffsetDateTime>,
pub original: Option<Decimal>,
pub scaled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefusalReason {
NoComparableHistory,
NoHeldValue,
Overflow,
AnchorOffGrid,
AnchorIncomplete,
AnchorExceeded {
actual: Decimal,
advance: Decimal,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Refusal {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub reason: RefusalReason,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct Filled {
pub series: Series,
pub substitutes: Vec<Substitute>,
pub refusals: Vec<Refusal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SubstituteError {
#[error("the report does not belong to this series")]
ReportMismatch,
#[error(transparent)]
Series(#[from] SeriesError),
}
pub fn substitute(
series: &Series,
report: &Report,
policy: &Policy,
) -> Result<Filled, SubstituteError> {
if !report.describes(series) {
return Err(SubstituteError::ReportMismatch);
}
let (boundary, resolution) = (series.boundary(), series.resolution());
let mut todo: BTreeMap<OffsetDateTime, (Period, SubstitutionReason)> = BTreeMap::new();
for f in report
.findings
.iter()
.filter(|f| f.severity() == Severity::Error)
{
let reason = match f.rule {
Rule::Negative | Rule::Capacity => SubstitutionReason::ImplausibleValue,
_ => policy.reason,
};
for slot in slots(boundary, resolution, f.from, f.to).filter(|s| s.start() >= f.from) {
todo.entry(slot.start()).or_insert((slot, reason));
}
}
let mut filler = Filler {
data: Data {
series,
prior: policy.history.as_ref(),
},
policy,
todo: &todo,
out: BTreeMap::new(),
refused: Vec::new(),
};
for block in blocks(todo.values().map(|(slot, _)| *slot)) {
let gap = block
.last()
.map_or(Duration::ZERO, |l| l.end() - block[0].start());
let (outage, rest): (Vec<Period>, Vec<Period>) =
block.into_iter().partition(|s| policy.in_outage(s));
for slot in outage {
filler.put(&slot, Decimal::ZERO, Method::Zero, gap, Vec::new());
}
for sub in blocks(rest) {
filler.fill(&sub, gap);
}
}
let Filler {
mut out,
mut refused,
..
} = filler;
if let Some((start, end)) = &policy.anchor
&& let Err(reason) = scale(series, &mut out, start, end)
{
refused.push((start.at, end.at, reason));
}
let mut intervals: Vec<MeterInterval> = series
.iter()
.filter(|iv| !out.contains_key(&iv.from()))
.map(|iv| {
if todo.contains_key(&iv.from()) {
iv.clone().with_quality(QualityFlag::Faulty)
} else {
iv.clone()
}
})
.collect();
intervals.extend(out.values().filter_map(|s| {
MeterInterval::build(
s.from,
s.to,
s.value,
QualityFlag::Substituted,
series.channel(),
)
.ok()
}));
Ok(Filled {
series: Series::new(resolution, boundary, intervals)?,
substitutes: out.into_values().collect(),
refusals: merge(refused),
})
}
fn blocks(slots: impl IntoIterator<Item = Period>) -> Vec<Vec<Period>> {
let mut out: Vec<Vec<Period>> = Vec::new();
for slot in slots {
match out.last_mut() {
Some(block) if block.last().is_some_and(|l| l.end() == slot.start()) => {
block.push(slot)
}
_ => out.push(vec![slot]),
}
}
out
}
fn merge(mut spans: Vec<(OffsetDateTime, OffsetDateTime, RefusalReason)>) -> Vec<Refusal> {
spans.sort_by_key(|(from, ..)| *from);
let mut out: Vec<Refusal> = Vec::new();
for (from, to, reason) in spans {
match out.last_mut() {
Some(last) if last.to == from && last.reason == reason => last.to = to,
_ => out.push(Refusal { from, to, reason }),
}
}
out
}
struct Filler<'a> {
data: Data<'a>,
policy: &'a Policy,
todo: &'a BTreeMap<OffsetDateTime, (Period, SubstitutionReason)>,
out: BTreeMap<OffsetDateTime, Substitute>,
refused: Vec<(OffsetDateTime, OffsetDateTime, RefusalReason)>,
}
impl Filler<'_> {
fn usable(&self, iv: &MeterInterval) -> bool {
is_actual(iv.quality())
&& !self.todo.contains_key(&iv.from())
&& !self
.policy
.outages
.iter()
.any(|(f, t)| *f <= iv.from() && iv.to() <= *t)
}
fn put(
&mut self,
slot: &Period,
value: Decimal,
method: Method,
gap: Duration,
references: Vec<OffsetDateTime>,
) {
let reason = self
.todo
.get(&slot.start())
.map_or(self.policy.reason, |(_, r)| *r);
self.out.insert(
slot.start(),
Substitute {
from: slot.start(),
to: slot.end(),
value: value.round_dp_with_strategy(SUBSTITUTE_DP, SUBSTITUTE_STRATEGY),
method,
code: method.market_code(self.policy.sparte),
reason,
gap,
references,
original: self.data.series.get(slot.start()).map(MeterInterval::value),
scaled: false,
},
);
}
fn refuse(&mut self, slot: &Period, reason: RefusalReason) {
self.refused.push((slot.start(), slot.end(), reason));
}
fn neighbour(&self, at: OffsetDateTime, ending: bool) -> Option<&MeterInterval> {
let series = self.data.series;
let iv = if ending {
let previous = series
.boundary()
.bucket(at - Duration::nanoseconds(1), series.resolution())?;
series.get(previous.start())?
} else {
series.get(at)?
};
self.usable(iv).then_some(iv)
}
fn fill(&mut self, sub: &[Period], gap: Duration) {
let (Some(first), Some(last)) = (sub.first(), sub.last()) else {
return;
};
let (start, end) = (first.start(), last.end());
let before = self.neighbour(start, true).cloned();
let after = self.neighbour(end, false).cloned();
if end - start <= self.policy.short
&& let (Some(a), Some(b)) = (&before, &after)
{
let k = Decimal::from(sub.len() + 1);
for (i, slot) in sub.iter().enumerate() {
let value = b
.value()
.checked_sub(a.value())
.and_then(|d| d.checked_mul(Decimal::from(i + 1)))
.and_then(|d| d.checked_div(k))
.and_then(|d| d.checked_add(a.value()));
match value {
Some(v) => self.put(
slot,
v,
Method::Interpolation,
gap,
vec![a.from(), b.from()],
),
None => self.refuse(slot, RefusalReason::Overflow),
}
}
return;
}
if self.policy.hold {
let held = self
.data
.series
.iter()
.rev()
.filter(|iv| iv.to() <= start)
.find(|iv| self.usable(iv))
.map(|iv| (iv.from(), iv.value()));
for slot in sub {
match held {
Some((at, v)) => self.put(slot, v, Method::Hold, gap, vec![at]),
None => self.refuse(slot, RefusalReason::NoHeldValue),
}
}
return;
}
for slot in sub {
let refs = references(
&self.data,
slot.start(),
start,
self.policy.land,
self.policy.days,
|iv| self.usable(iv),
);
let mean = refs
.iter()
.try_fold(Decimal::ZERO, |sum, (_, v)| sum.checked_add(*v))
.and_then(|sum| sum.checked_div(Decimal::from(refs.len())));
match mean {
_ if refs.is_empty() => self.refuse(slot, RefusalReason::NoComparableHistory),
Some(v) => {
let at = refs.iter().map(|(at, _)| *at).collect();
self.put(slot, v, Method::Vergleichswert, gap, at);
}
None => self.refuse(slot, RefusalReason::Overflow),
}
}
}
}
fn scale(
series: &Series,
out: &mut BTreeMap<OffsetDateTime, Substitute>,
start: &MeterReading,
end: &MeterReading,
) -> Result<(), RefusalReason> {
let (boundary, resolution) = (series.boundary(), series.resolution());
let on_grid = |at: OffsetDateTime| {
boundary
.bucket(at, resolution)
.is_some_and(|b| b.start() == at)
};
if !on_grid(start.at) || !on_grid(end.at) {
return Err(RefusalReason::AnchorOffGrid);
}
let advance = end
.value
.checked_sub(start.value)
.ok_or(RefusalReason::Overflow)?;
let mut actual = Decimal::ZERO;
let mut filled: Vec<OffsetDateTime> = Vec::new();
for slot in slots(boundary, resolution, start.at, end.at) {
if out.contains_key(&slot.start()) {
filled.push(slot.start());
continue;
}
let iv = series
.get(slot.start())
.filter(|iv| iv.quality().is_billable())
.ok_or(RefusalReason::AnchorIncomplete)?;
actual = actual
.checked_add(iv.value())
.ok_or(RefusalReason::Overflow)?;
}
let Some((&last, rest)) = filled.split_last() else {
return Ok(());
};
let target = advance.checked_sub(actual).ok_or(RefusalReason::Overflow)?;
if target < Decimal::ZERO {
return Err(RefusalReason::AnchorExceeded { actual, advance });
}
let raw = filled
.iter()
.filter_map(|at| out.get(at))
.try_fold(Decimal::ZERO, |sum, s| sum.checked_add(s.value))
.ok_or(RefusalReason::Overflow)?;
let n = Decimal::from(filled.len());
let mut values = Vec::with_capacity(rest.len());
let mut assigned = Decimal::ZERO;
for at in rest {
let s = out.get(at).ok_or(RefusalReason::AnchorIncomplete)?;
let v = if raw.is_zero() {
target.checked_div(n)
} else {
s.value.checked_mul(target).and_then(|p| p.checked_div(raw))
}
.ok_or(RefusalReason::Overflow)?
.round_dp_with_strategy(SUBSTITUTE_DP, SUBSTITUTE_STRATEGY);
assigned = assigned.checked_add(v).ok_or(RefusalReason::Overflow)?;
values.push((*at, v));
}
values.push((
last,
target
.checked_sub(assigned)
.ok_or(RefusalReason::Overflow)?,
));
for (at, v) in values {
if let Some(s) = out.get_mut(&at) {
s.value = v;
s.scaled = true;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
use crate::vee::validation::{Grade, Rules, validate};
use rust_decimal::dec;
use time::Date;
use time::macros::{date, datetime};
const NOW: OffsetDateTime = datetime!(2027-01-01 0:00 UTC);
const REASON: SubstitutionReason = SubstitutionReason::CommunicationFailure;
fn day(d: Date) -> Period {
DayBoundary::Strom.day(d).unwrap()
}
fn series_q(start: OffsetDateTime, values: &[Option<(Decimal, QualityFlag)>]) -> Series {
let ivs = values
.iter()
.enumerate()
.filter_map(|(i, v)| {
let from = start + Duration::minutes(15 * i as i64);
v.map(|(v, q)| MeterInterval::quarter_hour(from, v, q).unwrap())
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
fn series(start: OffsetDateTime, values: &[Option<Decimal>]) -> Series {
let v: Vec<_> = values
.iter()
.map(|v| v.map(|v| (v, QualityFlag::Measured)))
.collect();
series_q(start, &v)
}
fn run(s: &Series, period: Period, policy: &Policy) -> Filled {
let report = validate(
s,
&Rules::strom(period, NOW, None)
.without_spike()
.without_stale(),
);
substitute(s, &report, policy).unwrap()
}
fn policy() -> Policy {
Policy::strom(Bundesland::Be, REASON)
}
#[test]
fn the_meteringcode_interpolation_example() {
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(4.0)); 96];
let published = [
Some(dec!(4.000)),
Some(dec!(4.200)),
Some(dec!(4.300)),
Some(dec!(4.350)),
Some(dec!(4.300)),
None,
None,
None,
None,
Some(dec!(4.100)),
Some(dec!(3.900)),
Some(dec!(3.800)),
Some(dec!(3.900)),
];
v[..13].copy_from_slice(&published);
let filled = run(&series(d.start(), &v), d, &policy());
let values: Vec<Decimal> = filled.substitutes.iter().map(|s| s.value).collect();
assert_eq!(values, [dec!(4.26), dec!(4.22), dec!(4.18), dec!(4.14)]);
let s = &filled.substitutes[0];
assert_eq!(
(s.method, s.code, s.reason),
(Method::Interpolation, Some("Z92"), REASON)
);
assert_eq!(s.gap, Duration::hours(1));
assert_eq!(s.references.len(), 2);
let rules = Rules::strom(d, NOW, None).without_spike().without_stale();
assert_eq!(validate(&filled.series, &rules).grade(), Grade::A);
}
#[test]
fn the_short_gap_line_is_two_hours() {
let d = day(date!(2026 - 06 - 03));
for (missing, method) in [(8, Some(Method::Interpolation)), (9, None)] {
let mut v = vec![Some(dec!(1)); 96];
for slot in v.iter_mut().skip(40).take(missing) {
*slot = None;
}
let filled = run(&series(d.start(), &v), d, &policy());
assert_eq!(
filled.substitutes.first().map(|s| s.method),
method,
"{missing}"
);
if method.is_none() {
assert_eq!(
filled.refusals[0].reason,
RefusalReason::NoComparableHistory
);
assert_eq!(
filled.series.len(),
96 - missing,
"refused slots stay missing"
);
}
}
}
fn history_then_gap(gap_day: Date, land_policy: &Policy) -> Filled {
let start = day(date!(2026 - 05 - 25)).start();
let target = day(gap_day);
let n = ((target.end() - start).whole_minutes() / 15) as usize;
let mut v: Vec<Option<Decimal>> = (0..n)
.map(|i| {
let at = start + Duration::minutes(15 * i as i64);
let date = DayBoundary::Strom.day_of(at).unwrap();
Some(match date.weekday() {
time::Weekday::Sunday => dec!(1),
time::Weekday::Saturday => dec!(2),
_ => Decimal::from(date.day()),
})
})
.collect();
let first = ((target.start() - start).whole_minutes() / 15) as usize;
for slot in v.iter_mut().skip(first + 20).take(16) {
*slot = None;
}
run(&series(start, &v), target, land_policy)
}
#[test]
fn a_long_gap_is_the_mean_of_comparable_days_coded_zj2() {
let filled = history_then_gap(date!(2026 - 06 - 17), &policy());
assert_eq!(filled.substitutes.len(), 16);
let s = &filled.substitutes[0];
assert_eq!((s.method, s.code), (Method::Vergleichswert, Some("ZJ2")));
assert_eq!(s.value, dec!(14.333333));
assert_eq!(s.references.len(), 3);
assert_eq!(s.gap, Duration::hours(4));
let gas = Policy::gas(Bundesland::Be, REASON);
assert_eq!(Method::Vergleichswert.market_code(Sparte::Gas), Some("Z95"));
assert_eq!(
history_then_gap(date!(2026 - 06 - 17), &gas).substitutes[0].code,
Some("Z95")
);
}
#[test]
fn a_holiday_is_compared_with_sundays() {
let by = Policy::strom(Bundesland::By, REASON);
let be = policy();
let on_holiday = date!(2026 - 06 - 04);
let days = |p: &Policy| -> Vec<Date> {
history_then_gap(on_holiday, p).substitutes[0]
.references
.iter()
.filter_map(|at| DayBoundary::Strom.day_of(*at))
.collect()
};
assert_eq!(days(&by), [date!(2026 - 05 - 31), date!(2026 - 05 - 25)]);
assert_eq!(
days(&be),
[
date!(2026 - 06 - 03),
date!(2026 - 06 - 02),
date!(2026 - 06 - 01)
]
);
}
#[test]
fn an_edge_gap_is_not_interpolated() {
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(1)); 96];
v[95] = None;
let filled = run(&series(d.start(), &v), d, &policy());
assert!(filled.substitutes.is_empty());
assert_eq!(filled.refusals.len(), 1);
}
#[test]
fn no_value_is_interpolated_from_a_substitute() {
let d = day(date!(2026 - 06 - 03));
let m = Some((dec!(1), QualityFlag::Measured));
let mut v = vec![m; 96];
v[40] = Some((dec!(1), QualityFlag::Substituted));
v[41] = None;
let filled = run(&series_q(d.start(), &v), d, &policy());
assert!(filled.substitutes.is_empty());
assert_eq!(
filled.refusals[0].reason,
RefusalReason::NoComparableHistory
);
}
#[test]
fn a_rejected_value_is_substituted_with_its_original_kept() {
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(1)); 96];
v[40] = Some(dec!(-3));
let filled = run(&series(d.start(), &v), d, &policy());
let s = &filled.substitutes[0];
assert_eq!((s.value, s.original), (dec!(1), Some(dec!(-3))));
assert_eq!(s.reason, SubstitutionReason::ImplausibleValue);
assert_eq!(
filled.series.get(s.from).map(MeterInterval::quality),
Some(QualityFlag::Substituted)
);
}
#[test]
fn a_refused_rejected_value_is_flagged_faulty() {
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(1)); 96];
v[95] = Some(dec!(-3));
let filled = run(&series(d.start(), &v), d, &policy());
let iv = filled.series.get(d.end() - Duration::minutes(15)).unwrap();
assert_eq!((iv.value(), iv.quality()), (dec!(-3), QualityFlag::Faulty));
}
#[test]
fn hold_is_gas_only_and_zero_needs_a_stated_outage() {
assert_eq!(
policy().hold(),
Err(PolicyError::HoldNotCoded(Sparte::Strom))
);
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(1)); 96];
v[40] = Some(dec!(7));
for slot in v.iter_mut().skip(41).take(12) {
*slot = None;
}
let s = series(d.start(), &v);
let gas = Policy::gas(Bundesland::Be, REASON).hold().unwrap();
let held = run(&s, d, &gas);
assert!(
held.substitutes
.iter()
.all(|s| s.value == dec!(7) && s.code == Some("Z93"))
);
assert!(
run(&s, d, &policy())
.substitutes
.iter()
.all(|s| s.method != Method::Zero)
);
let gap = (
d.start() + Duration::minutes(15 * 41),
d.start() + Duration::minutes(15 * 53),
);
let zero = run(&s, d, &policy().outage(gap.0, gap.1));
assert!(
zero.substitutes
.iter()
.all(|s| s.method == Method::Zero && s.value.is_zero())
);
assert_eq!(zero.substitutes.len(), 12);
assert_eq!(zero.substitutes[0].code, None);
}
#[test]
fn the_register_anchor_conserves_energy_exactly() {
let d = day(date!(2026 - 06 - 03));
let mut v = vec![Some(dec!(0.25)); 96];
for slot in v.iter_mut().skip(10).take(3) {
*slot = None;
}
let s = series(d.start(), &v);
let a = MeterReading::measured(d.start(), dec!(100));
let b = MeterReading::measured(d.end(), dec!(124.1));
let filled = run(&s, d, &policy().anchor(a, b).unwrap());
assert!(filled.refusals.is_empty(), "{:?}", filled.refusals);
assert_eq!(filled.series.billable_total(), Some(dec!(24.1)));
assert!(filled.substitutes.iter().all(|s| s.scaled));
let low = MeterReading::measured(d.end(), dec!(110));
let a = MeterReading::measured(d.start(), dec!(100));
let filled = run(&s, d, &policy().anchor(a, low).unwrap());
assert!(matches!(
filled.refusals[0].reason,
RefusalReason::AnchorExceeded { .. }
));
assert!(filled.substitutes.iter().all(|s| !s.scaled));
}
#[test]
fn a_report_for_another_series_is_refused() {
let d = day(date!(2026 - 06 - 03));
let a = series(d.start(), &vec![Some(dec!(1)); 96]);
let b = series(d.start(), &vec![Some(dec!(1)); 95]);
let report = validate(&a, &Rules::strom(d, NOW, None));
assert_eq!(
substitute(&b, &report, &policy()),
Err(SubstituteError::ReportMismatch)
);
let reversed = policy().anchor(
MeterReading::measured(d.end(), dec!(1)),
MeterReading::measured(d.start(), dec!(2)),
);
assert!(matches!(reversed, Err(PolicyError::AnchorReversed { .. })));
}
}