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
}
}
#[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),
]);
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<_>>(),
)),
]);
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,
) -> 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(
col(column::FROM)
.gt_eq(ts(from))
.and(col(column::FROM).lt(ts(to))),
)?
.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()
}
pub(crate) async fn compute(
state: &dyn Session,
resolved: Arc<dyn TableProvider>,
table: &str,
discriminators: &[String],
from: OffsetDateTime,
to: OffsetDateTime,
) -> Result<Vec<Completeness>> {
if to <= from {
return Err(Error::config(format!(
"completeness range end {to} must be after start {from}"
)));
}
let plan = daily_plan(resolved, table, discriminators, from, to)?;
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)?);
}
Ok(roll_up(daily, from, to))
}
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;
type ChannelKey = (
String,
String,
Vec<(String, String)>,
&'static str,
Option<String>,
);
struct Accumulator {
identity: Vec<(String, String)>,
sparte: Sparte,
resolution: Option<String>,
expected: u64,
actual: u64,
missing: u64,
surplus: u64,
first_gap: Option<Date>,
substituted: u64,
not_billable: u64,
}
let mut by_channel: BTreeMap<ChannelKey, Accumulator> = BTreeMap::new();
let mut counted: std::collections::BTreeSet<(ChannelKey, Date)> = Default::default();
let mut per_day: BTreeMap<(ChannelKey, Date), (u64, u64)> = BTreeMap::new();
for row in daily {
let key: ChannelKey = (
row.malo_id.clone(),
row.obis_code.clone(),
row.identity.clone(),
row.sparte.as_str(),
row.resolution.clone(),
);
let entry = by_channel.entry(key.clone()).or_insert(Accumulator {
identity: row.identity.clone(),
sparte: row.sparte,
resolution: row.resolution.clone(),
expected: 0,
actual: 0,
missing: 0,
surplus: 0,
first_gap: None,
substituted: 0,
not_billable: 0,
});
entry.actual += row.actual;
entry.substituted += row.substituted;
entry.not_billable += row.not_billable;
let day_key = (key, row.day);
let expected = if counted.insert(day_key.clone()) {
let n = expected_in_day(row.day, row.resolution.as_deref(), row.sparte, from, to);
entry.expected += n;
n
} else {
0
};
let slot = per_day.entry(day_key).or_insert((0, 0));
slot.0 += row.actual;
slot.1 += expected;
}
for ((channel, day), (actual, expected)) in per_day {
let Some(entry) = by_channel.get_mut(&channel) else {
continue;
};
if expected == 0 {
continue;
}
if expected > actual {
entry.first_gap = Some(entry.first_gap.map_or(day, |d| d.min(day)));
}
entry.missing += expected.saturating_sub(actual);
entry.surplus += actual.saturating_sub(expected);
}
by_channel
.into_iter()
.map(
|((malo_id, obis_code, ..), a): (ChannelKey, Accumulator)| Completeness {
malo_id,
obis_code,
identity: a.identity,
sparte: a.sparte,
resolution: a.resolution,
expected: a.expected,
actual: a.actual,
missing: a.missing,
surplus: a.surplus,
first_gap: a.first_gap,
substituted: a.substituted,
not_billable: a.not_billable,
},
)
.collect()
}
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);
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
}
#[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, from, to) = match args {
[from, to] => (None, from, to),
[name, from, to] => (Some(as_string(name)?), from, to),
_ => {
return Err(DataFusionError::Plan(format!(
"{}(from, to) or {}(table, from, to)",
Self::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)?,
}))
}
}
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,
}
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)
.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,
self.from,
self.to,
)
.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 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 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 the_spring_forward_day_expects_92_not_96() {
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 out = roll_up(
vec![row(date!(2026 - 10 - 25), 96, "MEASURED")],
datetime!(2026-10-01 00:00 UTC),
datetime!(2026-11-01 00:00 UTC),
);
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 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 * 3 - (90 + 80 + 96));
}
#[test]
fn a_surplus_on_one_day_cannot_hide_a_gap_on_another() {
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 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 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 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 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 a_calendar_resolution_has_no_daily_expectation() {
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 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-03 00: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 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 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 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 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 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 out = roll_up(
vec![sparte_row(
Sparte::Gas,
date!(2026 - 10 - 24),
100,
"MEASURED",
)],
datetime!(2026-10-01 00:00 UTC),
datetime!(2026-11-01 00:00 UTC),
);
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 out = roll_up(
vec![
sparte_row(Sparte::Strom, date!(2026 - 03 - 02), 96, "MEASURED"),
sparte_row(Sparte::Gas, date!(2026 - 03 - 02), 96, "MEASURED"),
],
FROM,
TO,
);
assert_eq!(out.len(), 2);
assert!(out.iter().all(Completeness::is_complete));
}
}