use std::any::Any;
use std::sync::Arc;
use async_trait::async_trait;
use datafusion::catalog::{Session, TableProvider};
use datafusion::common::{DataFusionError, Result as DfResult, ScalarValue};
use datafusion::datasource::MemTable;
use datafusion::logical_expr::{
Expr, LogicalPlanBuilder, TableProviderFilterPushDown, TableType, col, lit,
};
use datafusion::physical_plan::ExecutionPlan;
use metering::IntervalResolution;
use metering::interval::Sparte;
use time::{Date, OffsetDateTime};
use crate::planner::calendar as balancing;
use crate::arrow::array::{Array, AsArray, Date32Array, Int64Array, RecordBatch, StringArray};
use crate::arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use crate::encode::schema::col as column;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completeness {
pub malo_id: String,
pub obis_code: String,
pub identity: Vec<(String, String)>,
pub sparte: Sparte,
pub resolution: Option<String>,
pub expected: u64,
pub actual: u64,
pub missing: u64,
pub surplus: u64,
pub first_gap: Option<Date>,
pub substituted: u64,
pub not_billable: u64,
}
impl Completeness {
pub fn is_complete(&self) -> bool {
self.missing == 0 && self.surplus == 0
}
pub fn is_measurable(&self) -> bool {
self.resolution.is_some() && self.expected > 0
}
pub fn is_silent(&self) -> bool {
self.actual == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DailyRow {
malo_id: String,
obis_code: String,
identity: Vec<(String, String)>,
sparte: Sparte,
resolution: Option<String>,
day: Date,
actual: u64,
substituted: u64,
not_billable: u64,
}
pub fn completeness_schema(discriminators: &[String]) -> SchemaRef {
let mut fields = vec![
Field::new("malo_id", DataType::Utf8, false),
Field::new("obis_code", DataType::Utf8, false),
];
fields.extend(
discriminators
.iter()
.map(|name| Field::new(name, DataType::Utf8, false)),
);
fields.extend([
Field::new("sparte", DataType::Utf8, false),
Field::new("resolution", DataType::Utf8, true),
Field::new("expected", DataType::Int64, false),
Field::new("actual", DataType::Int64, false),
Field::new("missing", DataType::Int64, false),
Field::new("surplus", DataType::Int64, false),
Field::new("first_gap", DataType::Date32, true),
Field::new("substituted", DataType::Int64, false),
Field::new("not_billable", DataType::Int64, false),
Field::new("complete", DataType::Boolean, false),
Field::new("measurable", DataType::Boolean, false),
]);
Arc::new(Schema::new(fields))
}
pub fn completeness_batch(rows: &[Completeness], discriminators: &[String]) -> Result<RecordBatch> {
use crate::arrow::array::BooleanArray;
let as_i64 = |v: u64| i64::try_from(v).unwrap_or(i64::MAX);
let mut columns: Vec<crate::arrow::array::ArrayRef> = vec![
Arc::new(StringArray::from(
rows.iter().map(|r| r.malo_id.as_str()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter()
.map(|r| r.obis_code.as_str())
.collect::<Vec<_>>(),
)),
];
for (i, name) in discriminators.iter().enumerate() {
columns.push(Arc::new(StringArray::from(
rows.iter()
.map(|r| match r.identity.get(i) {
Some((held, value)) if held == name => Ok(value.as_str()),
_ => Err(Error::encode(
name,
"completeness row carries no value for this merge-key column",
)),
})
.collect::<Result<Vec<_>>>()?,
)));
}
columns.extend::<Vec<crate::arrow::array::ArrayRef>>(vec![
Arc::new(StringArray::from(
rows.iter().map(|r| r.sparte.as_str()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter()
.map(|r| r.resolution.as_deref())
.collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| as_i64(r.expected)).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| as_i64(r.actual)).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| as_i64(r.missing)).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| as_i64(r.surplus)).collect::<Vec<_>>(),
)),
Arc::new(Date32Array::from(
rows.iter()
.map(|r| r.first_gap.map(crate::encode::schema::date32))
.collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter()
.map(|r| as_i64(r.substituted))
.collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter()
.map(|r| as_i64(r.not_billable))
.collect::<Vec<_>>(),
)),
Arc::new(BooleanArray::from(
rows.iter()
.map(Completeness::is_complete)
.collect::<Vec<_>>(),
)),
Arc::new(BooleanArray::from(
rows.iter()
.map(Completeness::is_measurable)
.collect::<Vec<_>>(),
)),
]);
Ok(RecordBatch::try_new(
completeness_schema(discriminators),
columns,
)?)
}
fn is_substitute(quality: &str) -> bool {
quality == metering::QualityFlag::Substituted.as_str()
}
fn is_billable(quality: &str) -> bool {
quality
.parse::<metering::QualityFlag>()
.map(|q| q.is_billable())
.unwrap_or(false)
}
fn daily_plan(
resolved: Arc<dyn TableProvider>,
table: &str,
discriminators: &[String],
from: OffsetDateTime,
to: OffsetDateTime,
narrowing: &[(String, ScalarValue)],
) -> DfResult<datafusion::logical_expr::LogicalPlan> {
use datafusion::functions_aggregate::expr_fn::count;
let ts = |t: OffsetDateTime| lit(crate::encode::schema::timestamp_scalar(t));
LogicalPlanBuilder::scan(
table.to_string(),
datafusion::datasource::provider_as_source(resolved),
None,
)?
.filter(narrow(
col(column::FROM)
.gt_eq(ts(from))
.and(col(column::FROM).lt(ts(to))),
narrowing,
))?
.aggregate(
{
let mut keys = vec![
col(column::MALO_ID),
col(column::OBIS_CODE),
col(column::SPARTE),
col(column::RESOLUTION),
col(column::BALANCING_DAY).alias("day"),
col(column::QUALITY),
];
keys.extend(discriminators.iter().map(col));
keys
},
vec![count(lit(1i64)).alias("actual")],
)?
.build()
}
fn narrow(range: Expr, narrowing: &[(String, ScalarValue)]) -> Expr {
narrowing.iter().fold(range, |acc, (name, value)| {
acc.and(col(name).eq(lit(value.clone())))
})
}
fn roster_plan(
resolved: Arc<dyn TableProvider>,
table: &str,
discriminators: &[String],
since: OffsetDateTime,
until: OffsetDateTime,
narrowing: &[(String, ScalarValue)],
) -> DfResult<datafusion::logical_expr::LogicalPlan> {
let ts = |t: OffsetDateTime| lit(crate::encode::schema::timestamp_scalar(t));
LogicalPlanBuilder::scan(
table.to_string(),
datafusion::datasource::provider_as_source(resolved),
None,
)?
.filter(narrow(
col(column::FROM)
.gt_eq(ts(since))
.and(col(column::FROM).lt(ts(until))),
narrowing,
))?
.aggregate(
{
let mut keys = vec![
col(column::MALO_ID),
col(column::OBIS_CODE),
col(column::SPARTE),
col(column::RESOLUTION),
];
keys.extend(discriminators.iter().map(col));
keys
},
Vec::<Expr>::new(),
)?
.build()
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Request<'a> {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub seen_since: Option<OffsetDateTime>,
pub narrowing: &'a [(String, ScalarValue)],
}
pub(crate) async fn compute(
state: &dyn Session,
resolved: Arc<dyn TableProvider>,
table: &str,
discriminators: &[String],
request: Request<'_>,
) -> Result<Vec<Completeness>> {
let Request {
from,
to,
seen_since,
narrowing,
} = request;
if to <= from {
return Err(Error::config(format!(
"completeness range end {to} must be after start {from}"
)));
}
if let Some(since) = seen_since
&& since >= from
{
return Err(Error::config(format!(
"the reference window {since} must start before the reported range {from}: \
a roster drawn from the range itself can only contain channels the range \
already reports, so it would find nothing silent"
)));
}
let plan = daily_plan(
Arc::clone(&resolved),
table,
discriminators,
from,
to,
narrowing,
)?;
let physical = state.create_physical_plan(&plan).await?;
let batches = datafusion::physical_plan::collect(physical, state.task_ctx()).await?;
let mut daily: Vec<DailyRow> = Vec::new();
for batch in &batches {
daily.extend(decode_daily(batch, discriminators)?);
}
let mut rows = roll_up(daily, from, to);
if let Some(since) = seen_since {
let plan = roster_plan(resolved, table, discriminators, since, from, narrowing)?;
let physical = state.create_physical_plan(&plan).await?;
let batches = datafusion::physical_plan::collect(physical, state.task_ctx()).await?;
let mut roster = Vec::new();
for batch in &batches {
roster.extend(decode_roster(batch, discriminators)?);
}
rows.extend(silent_rows(&rows, roster, from, to));
rows.sort_by(|a, b| {
(&a.malo_id, &a.obis_code, &a.identity, &a.resolution).cmp(&(
&b.malo_id,
&b.obis_code,
&b.identity,
&b.resolution,
))
});
}
Ok(rows)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Channel {
malo_id: String,
obis_code: String,
identity: Vec<(String, String)>,
sparte: Sparte,
resolution: Option<String>,
}
fn decode_roster(batch: &RecordBatch, discriminators: &[String]) -> Result<Vec<Channel>> {
let text = |name: &str| -> Result<&StringArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_string_opt::<i32>())
.ok_or_else(|| Error::decode(name, "expected a string column"))
};
let malo = text(column::MALO_ID)?;
let obis = text(column::OBIS_CODE)?;
let sparte = text(column::SPARTE)?;
let resolution = text(column::RESOLUTION)?;
let identity_columns = discriminators
.iter()
.map(|name| text(name))
.collect::<Result<Vec<_>>>()?;
(0..batch.num_rows())
.map(|i| {
Ok(Channel {
malo_id: malo.value(i).to_string(),
obis_code: obis.value(i).to_string(),
identity: discriminators
.iter()
.zip(&identity_columns)
.map(|(name, column)| (name.clone(), column.value(i).to_string()))
.collect(),
sparte: sparte.value(i).parse().map_err(|e| {
Error::decode(column::SPARTE, format!("{:?}: {e}", sparte.value(i)))
})?,
resolution: (!resolution.is_null(i)).then(|| resolution.value(i).to_string()),
})
})
.collect()
}
fn silent_rows(
reported: &[Completeness],
roster: Vec<Channel>,
from: OffsetDateTime,
to: OffsetDateTime,
) -> Vec<Completeness> {
use std::collections::BTreeSet;
type Key = (String, String, Vec<(String, String)>);
let key = |malo: &str, obis: &str, identity: &[(String, String)]| -> Key {
(malo.to_string(), obis.to_string(), identity.to_vec())
};
let present: BTreeSet<Key> = reported
.iter()
.map(|r| key(&r.malo_id, &r.obis_code, &r.identity))
.collect();
let mut seen: BTreeSet<Key> = BTreeSet::new();
roster
.into_iter()
.filter(|c| !present.contains(&key(&c.malo_id, &c.obis_code, &c.identity)))
.filter(|c| seen.insert(key(&c.malo_id, &c.obis_code, &c.identity)))
.map(|c| {
let (expected, first_gap) =
expected_over_range(from, to, c.resolution.as_deref(), c.sparte);
Completeness {
malo_id: c.malo_id,
obis_code: c.obis_code,
identity: c.identity,
sparte: c.sparte,
resolution: c.resolution,
expected,
actual: 0,
missing: expected,
surplus: 0,
first_gap,
substituted: 0,
not_billable: 0,
}
})
.collect()
}
fn expected_over_range(
from: OffsetDateTime,
to: OffsetDateTime,
resolution: Option<&str>,
sparte: Sparte,
) -> (u64, Option<Date>) {
let days = expectations(from, to, resolution, sparte);
let expected = days.iter().map(|(_, n)| n).sum();
let first_gap = days.iter().find(|(_, n)| *n > 0).map(|(day, _)| *day);
(expected, first_gap)
}
fn expectations(
from: OffsetDateTime,
to: OffsetDateTime,
resolution: Option<&str>,
sparte: Sparte,
) -> Vec<(Date, u64)> {
day_span(from, to, sparte)
.into_iter()
.map(|day| (day, expected_in_day(day, resolution, sparte, from, to)))
.collect()
}
fn day_span(from: OffsetDateTime, to: OffsetDateTime, sparte: Sparte) -> Vec<Date> {
let mut day = balancing::balancing_day(from, sparte);
let last = balancing::balancing_day(to - time::Duration::nanoseconds(1), sparte);
let mut out = Vec::new();
while day <= last {
out.push(day);
let Some(next) = day.next_day() else { break };
day = next;
}
out
}
fn decode_daily(batch: &RecordBatch, discriminators: &[String]) -> Result<Vec<DailyRow>> {
let text = |name: &str| -> Result<&StringArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_string_opt::<i32>())
.ok_or_else(|| Error::decode(name, "expected a string column"))
};
let malo = text(column::MALO_ID)?;
let obis = text(column::OBIS_CODE)?;
let sparte = text(column::SPARTE)?;
let resolution = text(column::RESOLUTION)?;
let quality = text(column::QUALITY)?;
let identity_columns = discriminators
.iter()
.map(|name| text(name))
.collect::<Result<Vec<_>>>()?;
let day = batch
.column_by_name("day")
.and_then(|c| c.as_any().downcast_ref::<Date32Array>())
.ok_or_else(|| Error::decode("day", "expected a date column"))?;
let actual = batch
.column_by_name("actual")
.and_then(|c| c.as_any().downcast_ref::<Int64Array>())
.ok_or_else(|| Error::decode("actual", "expected an i64 column"))?;
let mut out = Vec::with_capacity(batch.num_rows());
for i in 0..batch.num_rows() {
if day.is_null(i) {
continue;
}
let count = actual.value(i).max(0) as u64;
let flag = quality.value(i);
out.push(DailyRow {
malo_id: malo.value(i).to_string(),
obis_code: obis.value(i).to_string(),
identity: discriminators
.iter()
.zip(&identity_columns)
.map(|(name, column)| (name.clone(), column.value(i).to_string()))
.collect(),
sparte: sparte.value(i).parse().map_err(|e| {
Error::decode(column::SPARTE, format!("{:?}: {e}", sparte.value(i)))
})?,
resolution: (!resolution.is_null(i)).then(|| resolution.value(i).to_string()),
day: crate::encode::schema::date_of(day.value(i))?,
actual: count,
substituted: if is_substitute(flag) { count } else { 0 },
not_billable: if is_billable(flag) { 0 } else { count },
});
}
Ok(out)
}
fn roll_up(daily: Vec<DailyRow>, from: OffsetDateTime, to: OffsetDateTime) -> Vec<Completeness> {
use std::collections::{BTreeMap, BTreeSet};
type Attribution = (String, String, Vec<(String, String)>, &'static str);
type ChannelKey = (Attribution, Option<String>);
struct Cell {
identity: Vec<(String, String)>,
sparte: Sparte,
resolution: Option<String>,
actual: u64,
substituted: u64,
not_billable: u64,
delivered: BTreeMap<Date, u64>,
}
let mut cells: BTreeMap<ChannelKey, Cell> = BTreeMap::new();
let mut reported: BTreeMap<Attribution, BTreeMap<Date, BTreeSet<Option<String>>>> =
BTreeMap::new();
for row in daily {
let attribution: Attribution = (
row.malo_id.clone(),
row.obis_code.clone(),
row.identity.clone(),
row.sparte.as_str(),
);
let key: ChannelKey = (attribution.clone(), row.resolution.clone());
let cell = cells.entry(key).or_insert_with(|| Cell {
identity: row.identity.clone(),
sparte: row.sparte,
resolution: row.resolution.clone(),
actual: 0,
substituted: 0,
not_billable: 0,
delivered: BTreeMap::new(),
});
cell.actual += row.actual;
cell.substituted += row.substituted;
cell.not_billable += row.not_billable;
*cell.delivered.entry(row.day).or_insert(0) += row.actual;
reported
.entry(attribution)
.or_default()
.entry(row.day)
.or_default()
.insert(row.resolution);
}
let mut spans: BTreeMap<&'static str, Vec<Date>> = BTreeMap::new();
let mut grids: BTreeMap<(&'static str, Option<String>), Vec<u64>> = BTreeMap::new();
for (key, cell) in &cells {
let span = spans
.entry(key.0.3)
.or_insert_with(|| day_span(from, to, cell.sparte));
grids
.entry((key.0.3, cell.resolution.clone()))
.or_insert_with(|| {
span.iter()
.map(|day| {
expected_in_day(*day, cell.resolution.as_deref(), cell.sparte, from, to)
})
.collect()
});
}
#[derive(Default)]
struct Tally {
expected: u64,
missing: u64,
surplus: u64,
first_gap: Option<Date>,
}
let ordered: Vec<(ChannelKey, Cell)> = cells.into_iter().collect();
let mut out = Vec::with_capacity(ordered.len());
let mut start = 0;
while start < ordered.len() {
let attribution = ordered[start].0.0.clone();
let mut end = start;
while end < ordered.len() && ordered[end].0.0 == attribution {
end += 1;
}
let group = &ordered[start..end];
start = end;
let span = spans.get(&attribution.3).map(Vec::as_slice).unwrap_or(&[]);
let days_reported = reported.get(&attribution);
let mut in_force = days_reported
.and_then(|d| d.values().next())
.and_then(|grids| grids.first().cloned())
.unwrap_or_else(|| group[0].1.resolution.clone());
let expectations: Vec<&[u64]> = group
.iter()
.map(|(_, cell)| {
grids
.get(&(attribution.3, cell.resolution.clone()))
.map(Vec::as_slice)
.unwrap_or(&[])
})
.collect();
let mut tallies: Vec<Tally> = (0..group.len()).map(|_| Tally::default()).collect();
for (index, day) in span.iter().enumerate() {
let delivered_by = days_reported.and_then(|d| d.get(day));
if let Some(today) = delivered_by
&& !today.contains(&in_force)
&& let Some(next) = today.first()
{
in_force = next.clone();
}
for ((slot, (key, cell)), values) in tallies.iter_mut().zip(group).zip(&expectations) {
let actual = cell.delivered.get(day).copied();
let judges = actual.is_some() || (delivered_by.is_none() && in_force == key.1);
if !judges {
continue;
}
let expected = values.get(index).copied().unwrap_or(0);
if expected == 0 {
continue;
}
let actual = actual.unwrap_or(0);
slot.expected += expected;
slot.missing += expected.saturating_sub(actual);
slot.surplus += actual.saturating_sub(expected);
if expected > actual && slot.first_gap.is_none() {
slot.first_gap = Some(*day);
}
}
}
for ((key, cell), tally) in group.iter().zip(tallies) {
out.push(Completeness {
malo_id: key.0.0.clone(),
obis_code: key.0.1.clone(),
identity: cell.identity.clone(),
sparte: cell.sparte,
resolution: cell.resolution.clone(),
expected: tally.expected,
actual: cell.actual,
missing: tally.missing,
surplus: tally.surplus,
first_gap: tally.first_gap,
substituted: cell.substituted,
not_billable: cell.not_billable,
});
}
}
out
}
fn expected_in_day(
day: Date,
resolution: Option<&str>,
sparte: Sparte,
from: OffsetDateTime,
to: OffsetDateTime,
) -> u64 {
let Some(parsed) = resolution.and_then(|r| r.parse::<IntervalResolution>().ok()) else {
return 0;
};
let Some(full) = balancing::expected_intervals_in_balancing_day(day, parsed, sparte) else {
return 0;
};
let full = u64::from(full);
if full == 0 {
return 0;
}
let (start, end) = balancing::balancing_day_bounds(day, sparte);
let length = end - start;
if start >= from && end <= to {
return full;
}
let covered = end.min(to) - start.max(from);
if covered <= time::Duration::ZERO {
return 0;
}
let step = length / full as i32;
if step <= time::Duration::ZERO {
return 0;
}
(covered.whole_seconds() / step.whole_seconds()).max(0) as u64
}
pub struct CompletenessQuery<'a> {
store: &'a crate::session::MeterStore,
from: OffsetDateTime,
to: OffsetDateTime,
seen_since: Option<OffsetDateTime>,
narrowing: Vec<(String, ScalarValue)>,
}
impl std::fmt::Debug for CompletenessQuery<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompletenessQuery")
.field("from", &self.from)
.field("to", &self.to)
.field("seen_since", &self.seen_since)
.field("narrowing", &self.narrowing)
.finish_non_exhaustive()
}
}
impl<'a> CompletenessQuery<'a> {
pub(crate) fn new(
store: &'a crate::session::MeterStore,
from: OffsetDateTime,
to: OffsetDateTime,
) -> Self {
Self {
store,
from,
to,
seen_since: None,
narrowing: Vec::new(),
}
}
pub fn malo<M>(self, malo_id: M) -> Result<Self>
where
M: TryInto<metering::ids::MaloId>,
M::Error: std::fmt::Display,
{
let malo = crate::encode::parse_malo(malo_id)?;
self.column_eq(column::MALO_ID, ScalarValue::Utf8(Some(malo.to_string())))
}
pub fn melo<M>(self, melo_id: M) -> Result<Self>
where
M: TryInto<metering::ids::MeloId>,
M::Error: std::fmt::Display,
{
let melo = crate::encode::parse_melo(melo_id)?;
self.column_eq(column::MELO_ID, ScalarValue::Utf8(Some(melo.to_string())))
}
pub fn obis(self, obis_code: &str) -> Result<Self> {
let code = crate::encode::canonical_obis(obis_code)?;
self.column_eq(column::OBIS_CODE, ScalarValue::Utf8(Some(code)))
}
pub fn column_eq(mut self, name: &str, value: ScalarValue) -> Result<Self> {
let mut accepted: Vec<String> = vec![
column::MALO_ID.to_string(),
column::MELO_ID.to_string(),
column::OBIS_CODE.to_string(),
column::SPARTE.to_string(),
];
accepted.extend(
self.store
.config()
.extra_columns()
.iter()
.map(|f| f.name().clone()),
);
for column in self.store.config().discriminator_columns() {
if !accepted.contains(&column) {
accepted.push(column);
}
}
if !accepted.iter().any(|c| c == name) {
return Err(Error::config(format!(
"{name:?} is not a filterable column of {}: this store accepts [{}]. \
Column names are written into SQL as identifiers, which cannot be \
parameterised, so only declared ones are accepted",
self.store.table(),
accepted.join(", "),
)));
}
self.narrowing.push((name.to_string(), value));
Ok(self)
}
#[must_use]
pub fn seen_since(mut self, since: OffsetDateTime) -> Self {
self.seen_since = Some(since);
self
}
pub async fn run(self) -> Result<Vec<Completeness>> {
let (resolved, table, discriminators) = self.store.completeness_inputs().await?;
compute(
&self.store.context().state(),
resolved,
&table,
&discriminators,
Request {
from: self.from,
to: self.to,
seen_since: self.seen_since,
narrowing: &self.narrowing,
},
)
.await
}
}
impl<'a> std::future::IntoFuture for CompletenessQuery<'a> {
type Output = Result<Vec<Completeness>>;
type IntoFuture =
std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'a>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.run())
}
}
#[derive(Debug)]
pub struct CompletenessFunction {
resolved: Arc<dyn TableProvider>,
table: String,
discriminators: Vec<String>,
}
impl CompletenessFunction {
pub fn new(
resolved: Arc<dyn TableProvider>,
table: impl Into<String>,
discriminators: Vec<String>,
) -> Self {
Self {
resolved,
table: table.into(),
discriminators,
}
}
pub const NAME: &'static str = "meter_completeness";
}
impl datafusion::catalog::TableFunctionImpl for CompletenessFunction {
fn call(&self, args: &[Expr]) -> DfResult<Arc<dyn TableProvider>> {
let (name, seen_since, from, to) = match args {
[from, to] => (None, None, from, to),
[first, from, to] if as_instant(first).is_ok() => (None, Some(first), from, to),
[name, from, to] => (Some(as_string(name)?), None, from, to),
[name, since, from, to] => (Some(as_string(name)?), Some(since), from, to),
_ => {
return Err(DataFusionError::Plan(format!(
"{name}(from, to), {name}(seen_since, from, to), \
{name}(table, from, to) or {name}(table, seen_since, from, to)",
name = Self::NAME,
)));
}
};
if let Some(requested) = name
&& requested != self.table
&& requested != crate::session::store::resolved_name(&self.table)
{
return Err(DataFusionError::Plan(format!(
"this store manages {:?}, not {requested:?}",
self.table
)));
}
Ok(Arc::new(CompletenessProvider {
resolved: Arc::clone(&self.resolved),
table: self.table.clone(),
discriminators: self.discriminators.clone(),
from: as_instant(from)?,
to: as_instant(to)?,
seen_since: seen_since.map(as_instant).transpose()?,
}))
}
}
fn as_string(expr: &Expr) -> DfResult<String> {
match expr {
Expr::Literal(ScalarValue::Utf8(Some(s)), _) => Ok(s.clone()),
other => Err(DataFusionError::Plan(format!(
"expected a string literal, got {other}"
))),
}
}
fn as_instant(expr: &Expr) -> DfResult<OffsetDateTime> {
use time::format_description::well_known::Rfc3339;
let micros = match expr {
Expr::Literal(ScalarValue::TimestampMicrosecond(Some(v), _), _) => Some(*v),
Expr::Literal(ScalarValue::TimestampMillisecond(Some(v), _), _) => Some(v * 1_000),
Expr::Literal(ScalarValue::TimestampSecond(Some(v), _), _) => Some(v * 1_000_000),
Expr::Literal(ScalarValue::TimestampNanosecond(Some(v), _), _) => Some(v / 1_000),
Expr::Literal(ScalarValue::Date32(Some(days)), _) => {
Some(i64::from(*days) * 86_400 * 1_000_000)
}
_ => None,
};
if let Some(micros) = micros {
return OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
.map_err(|e| DataFusionError::Plan(format!("timestamp out of range: {e}")));
}
let text = as_string(expr)?;
if let Ok(t) = OffsetDateTime::parse(&text, &Rfc3339) {
return Ok(t);
}
time::Date::parse(
&text,
&time::macros::format_description!("[year]-[month]-[day]"),
)
.map(|d| d.midnight().assume_utc())
.map_err(|e| {
DataFusionError::Plan(format!(
"{text:?} is not an RFC 3339 timestamp or a YYYY-MM-DD date: {e}"
))
})
}
struct CompletenessProvider {
resolved: Arc<dyn TableProvider>,
table: String,
discriminators: Vec<String>,
from: OffsetDateTime,
to: OffsetDateTime,
seen_since: Option<OffsetDateTime>,
}
impl std::fmt::Debug for CompletenessProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompletenessProvider")
.field("table", &self.table)
.field("from", &self.from)
.field("to", &self.to)
.field("seen_since", &self.seen_since)
.finish()
}
}
#[async_trait]
impl TableProvider for CompletenessProvider {
fn as_any(&self) -> &dyn Any {
self
}
fn schema(&self) -> SchemaRef {
completeness_schema(&self.discriminators)
}
fn table_type(&self) -> TableType {
TableType::View
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
filters: &[Expr],
limit: Option<usize>,
) -> DfResult<Arc<dyn ExecutionPlan>> {
let rows = compute(
state,
Arc::clone(&self.resolved),
&self.table,
&self.discriminators,
Request {
from: self.from,
to: self.to,
seen_since: self.seen_since,
narrowing: &[],
},
)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let batch = completeness_batch(&rows, &self.discriminators)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
MemTable::try_new(completeness_schema(&self.discriminators), vec![vec![batch]])?
.scan(state, projection, filters, limit)
.await
}
fn supports_filters_pushdown(
&self,
filters: &[&Expr],
) -> DfResult<Vec<TableProviderFilterPushDown>> {
Ok(vec![TableProviderFilterPushDown::Inexact; filters.len()])
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::{date, datetime};
const FROM: OffsetDateTime = datetime!(2026-03-01 00:00 UTC);
const TO: OffsetDateTime = datetime!(2026-04-01 00:00 UTC);
fn over(first: Date, last: Date, sparte: Sparte) -> (OffsetDateTime, OffsetDateTime) {
(
balancing::balancing_day_bounds(first, sparte).0,
balancing::balancing_day_bounds(last, sparte).1,
)
}
fn day(d: Date) -> (OffsetDateTime, OffsetDateTime) {
over(d, d, Sparte::Strom)
}
fn row(day: Date, actual: u64, quality: &str) -> DailyRow {
sparte_row(Sparte::Strom, day, actual, quality)
}
fn sparte_row(sparte: Sparte, day: Date, actual: u64, quality: &str) -> DailyRow {
DailyRow {
malo_id: "12345678905".into(),
obis_code: "1-0:1.8.0".into(),
identity: Vec::new(),
sparte,
resolution: Some("PT15M".into()),
day,
actual,
substituted: if quality == "SUBSTITUTED" { actual } else { 0 },
not_billable: if is_billable(quality) { 0 } else { actual },
}
}
#[test]
fn a_full_ordinary_day_is_complete() {
let (from, to) = day(date!(2026 - 03 - 02));
let out = roll_up(vec![row(date!(2026 - 03 - 02), 96, "MEASURED")], from, to);
assert_eq!(out.len(), 1);
assert_eq!(out[0].expected, 96);
assert_eq!(out[0].actual, 96);
assert!(out[0].is_complete());
assert_eq!(out[0].first_gap, None);
}
#[test]
fn a_channel_that_stopped_mid_range_is_short_by_every_day_after() {
let (from, to) = over(date!(2026 - 03 - 01), date!(2026 - 03 - 31), Sparte::Strom);
let out = roll_up(
vec![
row(date!(2026 - 03 - 01), 96, "MEASURED"),
row(date!(2026 - 03 - 02), 96, "MEASURED"),
],
from,
to,
);
assert!(!out[0].is_complete(), "two days out of thirty-one");
assert_eq!(out[0].actual, 192);
assert_eq!(out[0].expected, 96 * 30 + 92);
assert_eq!(out[0].missing, 96 * 30 + 92 - 192);
assert_eq!(
out[0].surplus, 0,
"a day nobody delivered is not a duplicate"
);
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 03)));
}
#[test]
fn a_day_missing_from_the_middle_is_missing() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 04), Sparte::Strom);
let out = roll_up(
vec![
row(date!(2026 - 03 - 02), 96, "MEASURED"),
row(date!(2026 - 03 - 04), 96, "MEASURED"),
],
from,
to,
);
assert_eq!(out[0].expected, 96 * 3);
assert_eq!(out[0].actual, 96 * 2);
assert_eq!(out[0].missing, 96);
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 03)));
}
#[test]
fn the_spring_forward_day_expects_92_not_96() {
let (from, to) = day(date!(2026 - 03 - 29));
let out = roll_up(vec![row(date!(2026 - 03 - 29), 92, "MEASURED")], from, to);
assert_eq!(out[0].expected, 92);
assert!(out[0].is_complete(), "92 intervals is a complete DST day");
}
#[test]
fn the_autumn_day_expects_100_so_a_gap_is_visible() {
let (from, to) = day(date!(2026 - 10 - 25));
let out = roll_up(vec![row(date!(2026 - 10 - 25), 96, "MEASURED")], from, to);
assert_eq!(out[0].expected, 100);
assert_eq!(out[0].missing, 4);
assert!(!out[0].is_complete());
assert_eq!(out[0].first_gap, Some(date!(2026 - 10 - 25)));
}
#[test]
fn the_first_gap_is_the_earliest_short_day() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 05), Sparte::Strom);
let out = roll_up(
vec![
row(date!(2026 - 03 - 05), 90, "MEASURED"),
row(date!(2026 - 03 - 02), 80, "MEASURED"),
row(date!(2026 - 03 - 03), 96, "MEASURED"),
],
from,
to,
);
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 02)));
assert_eq!(out[0].missing, 96 * 4 - (90 + 80 + 96));
}
#[test]
fn a_surplus_on_one_day_cannot_hide_a_gap_on_another() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 03), Sparte::Strom);
let out = roll_up(
vec![
row(date!(2026 - 03 - 02), 100, "MEASURED"),
row(date!(2026 - 03 - 03), 92, "MEASURED"),
],
from,
to,
);
assert_eq!(out[0].expected, 192);
assert_eq!(out[0].actual, 192);
assert_eq!(out[0].missing, 4, "the 3rd is four intervals short");
assert_eq!(out[0].surplus, 4, "the 2nd holds four too many");
assert!(!out[0].is_complete());
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 03)));
}
#[test]
fn substitutes_and_unbillable_rows_are_counted_separately() {
let (from, to) = day(date!(2026 - 03 - 02));
let out = roll_up(
vec![
row(date!(2026 - 03 - 02), 90, "MEASURED"),
row(date!(2026 - 03 - 02), 6, "SUBSTITUTED"),
],
from,
to,
);
assert_eq!(out[0].actual, 96);
assert!(out[0].is_complete());
assert_eq!(out[0].substituted, 6);
assert_eq!(
out[0].not_billable, 0,
"SUBSTITUTED is billable under § 60 Abs. 2 MsbG"
);
}
#[test]
fn a_faulty_reading_is_present_but_not_billable() {
let (from, to) = day(date!(2026 - 03 - 02));
let out = roll_up(
vec![
row(date!(2026 - 03 - 02), 90, "MEASURED"),
row(date!(2026 - 03 - 02), 6, "FAULTY"),
],
from,
to,
);
assert!(out[0].is_complete(), "the intervals are present");
assert_eq!(out[0].not_billable, 6, "and six of them cannot be billed");
}
#[test]
fn a_series_with_no_resolution_is_not_measurable() {
let (from, to) = day(date!(2026 - 03 - 02));
let mut r = row(date!(2026 - 03 - 02), 24, "MEASURED");
r.resolution = None;
let out = roll_up(vec![r], from, to);
assert!(!out[0].is_measurable());
assert_eq!(out[0].expected, 0);
assert_eq!(out[0].actual, 24);
assert_eq!(
out[0].surplus, 0,
"with nothing to compare against, every row is not surplus"
);
assert_eq!(out[0].missing, 0);
assert_eq!(out[0].first_gap, None);
}
#[test]
fn a_channel_that_changes_grid_reports_one_row_per_grid() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 03), Sparte::Strom);
let mut hourly = row(date!(2026 - 03 - 02), 24, "MEASURED");
hourly.resolution = Some("PT1H".into());
let quarterly = row(date!(2026 - 03 - 03), 96, "MEASURED");
let out = roll_up(vec![hourly, quarterly], from, to);
assert_eq!(out.len(), 2, "one row per grid: {out:?}");
let by_grid: std::collections::BTreeMap<_, _> = out
.iter()
.map(|r| (r.resolution.clone().unwrap(), r))
.collect();
let hourly = by_grid["PT1H"];
assert_eq!((hourly.expected, hourly.actual), (24, 24));
assert!(hourly.is_complete() && hourly.is_measurable());
let quarterly = by_grid["PT15M"];
assert_eq!((quarterly.expected, quarterly.actual), (96, 96));
assert!(quarterly.is_complete() && quarterly.is_measurable());
}
#[test]
fn an_absent_day_goes_to_the_grid_that_was_in_force() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 06), Sparte::Strom);
let mut hourly = row(date!(2026 - 03 - 02), 24, "MEASURED");
hourly.resolution = Some("PT1H".into());
let out = roll_up(
vec![
hourly,
row(date!(2026 - 03 - 05), 96, "MEASURED"),
row(date!(2026 - 03 - 06), 96, "MEASURED"),
],
from,
to,
);
let by_grid: std::collections::BTreeMap<_, _> = out
.iter()
.map(|r| (r.resolution.clone().unwrap(), r))
.collect();
let hourly = by_grid["PT1H"];
assert_eq!(
hourly.expected,
24 * 3,
"the 2nd it delivered, plus the 3rd and 4th"
);
assert_eq!(hourly.missing, 24 * 2);
assert_eq!(hourly.first_gap, Some(date!(2026 - 03 - 03)));
let quarterly = by_grid["PT15M"];
assert_eq!(quarterly.expected, 96 * 2, "only the days it was in force");
assert!(quarterly.is_complete(), "{quarterly:?}");
}
#[test]
fn a_conversion_day_does_not_move_the_grid_while_the_old_one_still_delivers() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 04), Sparte::Strom);
let hourly = |day: Date, actual: u64| {
let mut r = row(day, actual, "MEASURED");
r.resolution = Some("PT1H".into());
r
};
let forward = roll_up(
vec![
hourly(date!(2026 - 03 - 02), 24),
hourly(date!(2026 - 03 - 03), 12),
row(date!(2026 - 03 - 03), 48, "MEASURED"),
],
from,
to,
);
let reversed = roll_up(
vec![
row(date!(2026 - 03 - 03), 48, "MEASURED"),
hourly(date!(2026 - 03 - 03), 12),
hourly(date!(2026 - 03 - 02), 24),
],
from,
to,
);
assert_eq!(
forward, reversed,
"the report must not depend on scan order"
);
let by_grid: std::collections::BTreeMap<_, _> = forward
.iter()
.map(|r| (r.resolution.clone().unwrap(), r))
.collect();
assert_eq!(by_grid["PT1H"].expected, 24 * 3);
assert_eq!(by_grid["PT1H"].first_gap, Some(date!(2026 - 03 - 03)));
assert_eq!(by_grid["PT15M"].expected, 96);
}
#[test]
fn a_gap_before_the_first_delivery_goes_to_the_grid_that_followed_it() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 04), Sparte::Strom);
let out = roll_up(vec![row(date!(2026 - 03 - 04), 96, "MEASURED")], from, to);
assert_eq!(out.len(), 1);
assert_eq!(out[0].expected, 96 * 3);
assert_eq!(out[0].missing, 96 * 2);
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 02)));
}
#[test]
fn a_gas_channel_that_stopped_is_short_on_gastage() {
let (from, to) = over(date!(2026 - 10 - 23), date!(2026 - 10 - 25), Sparte::Gas);
let out = roll_up(
vec![sparte_row(
Sparte::Gas,
date!(2026 - 10 - 23),
96,
"MEASURED",
)],
from,
to,
);
assert_eq!(out[0].expected, 96 + 100 + 96);
assert_eq!(out[0].missing, 100 + 96);
assert_eq!(out[0].first_gap, Some(date!(2026 - 10 - 24)));
}
#[test]
fn a_calendar_resolution_has_no_daily_expectation() {
let (from, to) = day(date!(2026 - 03 - 02));
let mut r = row(date!(2026 - 03 - 02), 1, "MEASURED");
r.resolution = Some("P1M".into());
let out = roll_up(vec![r], from, to);
assert_eq!(out[0].expected, 0, "a month is not n intervals in a day");
assert!(!out[0].is_measurable());
assert_eq!(out[0].surplus, 0);
}
#[test]
fn duplicates_are_surplus_rather_than_negative_gaps() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 03), Sparte::Strom);
let out = roll_up(
vec![
row(date!(2026 - 03 - 02), 120, "MEASURED"),
row(date!(2026 - 03 - 03), 90, "MEASURED"),
],
from,
to,
);
assert_eq!(out[0].surplus, 24);
assert_eq!(out[0].first_gap, Some(date!(2026 - 03 - 03)));
assert!(!out[0].is_complete());
}
#[test]
fn a_range_that_covers_part_of_a_day_expects_part_of_it() {
let out = roll_up(
vec![row(date!(2026 - 03 - 02), 48, "MEASURED")],
datetime!(2026-03-02 11:00 UTC), datetime!(2026-03-02 23:00 UTC), );
assert_eq!(out[0].expected, 48);
assert!(out[0].is_complete());
}
#[test]
fn channels_are_reported_separately() {
let mut second = row(date!(2026 - 03 - 02), 96, "MEASURED");
second.obis_code = "1-0:2.8.0".into();
let (from, to) = day(date!(2026 - 03 - 02));
let out = roll_up(
vec![row(date!(2026 - 03 - 02), 96, "MEASURED"), second],
from,
to,
);
assert_eq!(out.len(), 2);
}
#[test]
fn a_batch_matches_the_published_schema() {
let (from, to) = day(date!(2026 - 03 - 02));
let rows = roll_up(vec![row(date!(2026 - 03 - 02), 90, "MEASURED")], from, to);
let batch = completeness_batch(&rows, &[]).unwrap();
assert_eq!(batch.schema(), completeness_schema(&[]));
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn the_sql_surface_reports_measurability_beside_completeness() {
use crate::arrow::array::AsArray;
let (from, to) = day(date!(2026 - 03 - 02));
let judged = row(date!(2026 - 03 - 02), 96, "MEASURED");
let mut unjudgeable = row(date!(2026 - 03 - 02), 1, "MEASURED");
unjudgeable.obis_code = "1-0:2.8.0".into();
unjudgeable.resolution = Some("P1M".into());
let rows = roll_up(vec![judged, unjudgeable], from, to);
let batch = completeness_batch(&rows, &[]).unwrap();
assert_eq!(batch.schema(), completeness_schema(&[]));
let flag = |name: &str| {
batch
.column_by_name(name)
.unwrap_or_else(|| panic!("{name} is a published column"))
.as_boolean()
.iter()
.map(|v| v.expect("not null"))
.collect::<Vec<_>>()
};
assert_eq!(flag("complete"), vec![true, true]);
assert_eq!(flag("measurable"), vec![true, false]);
assert_eq!(
flag("measurable"),
rows.iter()
.map(Completeness::is_measurable)
.collect::<Vec<_>>(),
"the column and the Rust accessor must not drift"
);
}
#[test]
fn two_readings_of_one_channel_are_two_rows() {
let of = |tenant: &str, actual: u64| {
let mut r = row(date!(2026 - 03 - 02), actual, "MEASURED");
r.identity = vec![("tenant".to_string(), tenant.to_string())];
r
};
let (from, to) = day(date!(2026 - 03 - 02));
let out = roll_up(vec![of("a", 96), of("b", 92)], from, to);
assert_eq!(out.len(), 2, "two readings, two rows");
let tenant_a = out.iter().find(|r| r.identity[0].1 == "a").unwrap();
let tenant_b = out.iter().find(|r| r.identity[0].1 == "b").unwrap();
assert!(tenant_a.is_complete(), "a delivered the whole day");
assert_eq!(tenant_b.missing, 4, "and b is four short, on its own row");
assert_eq!(tenant_a.surplus, 0, "neither is a duplicate of the other");
}
#[test]
fn the_reported_columns_follow_the_merge_key() {
let mut r = row(date!(2026 - 03 - 02), 96, "MEASURED");
r.identity = vec![("melo_id".to_string(), "DE00012345".to_string())];
let (from, to) = day(date!(2026 - 03 - 02));
let rows = roll_up(vec![r], from, to);
let key = ["melo_id".to_string()];
let batch = completeness_batch(&rows, &key).unwrap();
assert_eq!(batch.schema(), completeness_schema(&key));
use crate::arrow::array::AsArray;
let column = batch.column_by_name("melo_id").expect("reported");
assert_eq!(column.as_string::<i32>().value(0), "DE00012345");
}
#[test]
fn expected_in_day_knows_the_dst_days() {
assert_eq!(
expected_in_day(
date!(2026 - 03 - 29),
Some("PT15M"),
Sparte::Strom,
FROM,
TO
),
92
);
assert_eq!(
expected_in_day(
date!(2026 - 10 - 25),
Some("PT15M"),
Sparte::Strom,
datetime!(2026-10-01 00:00 UTC),
datetime!(2026-11-01 00:00 UTC)
),
100
);
}
#[test]
fn a_gas_channels_dst_day_is_the_saturday_not_the_sunday() {
let october = (
datetime!(2026-10-01 00:00 UTC),
datetime!(2026-11-01 00:00 UTC),
);
for (day, gas, strom) in [
(date!(2026 - 10 - 24), 100, 96),
(date!(2026 - 10 - 25), 96, 100),
] {
assert_eq!(
expected_in_day(day, Some("PT15M"), Sparte::Gas, october.0, october.1),
gas,
"gas {day}"
);
assert_eq!(
expected_in_day(day, Some("PT15M"), Sparte::Strom, october.0, october.1),
strom,
"strom {day}"
);
}
}
#[test]
fn a_full_gastag_is_complete_and_the_calendar_day_would_not_be() {
let (from, to) = over(date!(2026 - 10 - 24), date!(2026 - 10 - 24), Sparte::Gas);
let out = roll_up(
vec![sparte_row(
Sparte::Gas,
date!(2026 - 10 - 24),
100,
"MEASURED",
)],
from,
to,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].sparte, Sparte::Gas);
assert_eq!(out[0].expected, 100);
assert!(out[0].is_complete());
assert_eq!(out[0].surplus, 0);
}
#[test]
fn heat_and_water_keep_the_calendar_day() {
for sparte in [Sparte::Waerme, Sparte::Wasser] {
assert_eq!(
expected_in_day(
date!(2026 - 10 - 25),
Some("PT15M"),
sparte,
datetime!(2026-10-01 00:00 UTC),
datetime!(2026-11-01 00:00 UTC),
),
100,
"{sparte}"
);
}
}
#[test]
fn a_gas_day_clipped_by_the_range_expects_only_the_covered_part() {
let from = datetime!(2026-07-14 22:00 UTC); let to = datetime!(2026-07-21 22:00 UTC);
assert_eq!(
expected_in_day(date!(2026 - 07 - 14), Some("PT15M"), Sparte::Gas, from, to),
24,
"the 00:00–06:00 tail of the Gastag that began on the 14th"
);
assert_eq!(
expected_in_day(date!(2026 - 07 - 15), Some("PT15M"), Sparte::Gas, from, to),
96,
"wholly inside the range"
);
}
#[test]
fn two_commodities_on_one_channel_are_reported_separately() {
let (from, to) = over(date!(2026 - 03 - 02), date!(2026 - 03 - 02), Sparte::Strom);
let out = roll_up(
vec![
sparte_row(Sparte::Strom, date!(2026 - 03 - 02), 96, "MEASURED"),
sparte_row(Sparte::Gas, date!(2026 - 03 - 01), 24, "MEASURED"),
sparte_row(Sparte::Gas, date!(2026 - 03 - 02), 72, "MEASURED"),
],
from,
to,
);
assert_eq!(out.len(), 2);
let strom = out.iter().find(|r| r.sparte == Sparte::Strom).unwrap();
assert_eq!((strom.expected, strom.actual), (96, 96));
assert!(strom.is_complete());
let gas = out.iter().find(|r| r.sparte == Sparte::Gas).unwrap();
assert_eq!((gas.expected, gas.actual), (96, 96));
assert!(
gas.is_complete(),
"clipped on the Gastag rather than on the calendar day: {gas:?}"
);
}
fn channel(sparte: Sparte, resolution: Option<&str>) -> Channel {
Channel {
malo_id: "12345678905".into(),
obis_code: "1-0:1.8.0".into(),
identity: Vec::new(),
sparte,
resolution: resolution.map(str::to_string),
}
}
#[test]
fn a_channel_that_delivered_nothing_is_the_finding_the_range_cannot_make() {
let silent = silent_rows(&[], vec![channel(Sparte::Strom, Some("PT15M"))], FROM, TO);
assert_eq!(silent.len(), 1);
let row = &silent[0];
assert!(row.is_silent());
assert!(!row.is_complete());
assert_eq!(row.actual, 0);
assert_eq!(row.expected, 31 * 96);
assert_eq!(row.missing, row.expected, "the whole range is missing");
assert_eq!(row.surplus, 0);
assert_eq!(row.first_gap, Some(date!(2026 - 03 - 01)));
}
#[test]
fn a_channel_still_reporting_is_not_called_silent() {
let reported = roll_up(vec![row(date!(2026 - 03 - 02), 96, "MEASURED")], FROM, TO);
let silent = silent_rows(
&reported,
vec![channel(Sparte::Strom, Some("PT1H"))],
FROM,
TO,
);
assert!(silent.is_empty(), "{silent:?}");
}
#[test]
fn a_silent_gas_channel_is_measured_on_the_gastag() {
let silent = silent_rows(&[], vec![channel(Sparte::Gas, Some("PT15M"))], FROM, TO);
assert_eq!(silent.len(), 1);
assert_eq!(silent[0].expected, 31 * 96);
assert_eq!(silent[0].first_gap, Some(date!(2026 - 02 - 28)));
}
#[test]
fn a_silent_channel_with_no_grid_reports_nothing_to_judge() {
let silent = silent_rows(&[], vec![channel(Sparte::Strom, None)], FROM, TO);
assert_eq!(silent.len(), 1);
assert_eq!(silent[0].expected, 0);
assert_eq!(silent[0].missing, 0);
assert!(!silent[0].is_measurable());
assert_eq!(silent[0].first_gap, None);
}
#[test]
fn a_channel_that_changed_grid_and_went_silent_is_one_row() {
let silent = silent_rows(
&[],
vec![
channel(Sparte::Strom, Some("PT1H")),
channel(Sparte::Strom, Some("PT15M")),
],
FROM,
TO,
);
assert_eq!(silent.len(), 1);
}
#[test]
fn identity_keeps_two_tenants_silent_channels_apart() {
let tenant = |who: &str| Channel {
identity: vec![("tenant".into(), who.into())],
..channel(Sparte::Strom, Some("PT15M"))
};
let reported = roll_up(
vec![DailyRow {
identity: vec![("tenant".into(), "a".into())],
..row(date!(2026 - 03 - 02), 96, "MEASURED")
}],
FROM,
TO,
);
let silent = silent_rows(&reported, vec![tenant("a"), tenant("b")], FROM, TO);
assert_eq!(silent.len(), 1);
assert_eq!(silent[0].identity, vec![("tenant".into(), "b".into())]);
}
}