pub mod schema;
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::arrow::array::{
Array, ArrayRef, Decimal128Array, RecordBatch, StringArray, TimestampMicrosecondArray,
};
use crate::arrow::datatypes::Field;
use datafusion::common::ScalarValue;
use metering::ids::{MaloId, MeloId};
use metering::interval::{MeasurementUnit, MeterInterval, Sparte};
use metering::measurement_series::{MeasurementSeries, MeasurementSource, ProvenanceEntry};
use metering::obis::ObisCode;
use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::error::{Error, Result};
use crate::version::{ScopedVersion, Version, VersionScope};
use schema::{VALUE_PRECISION, VALUE_SCALE, VERSION_PRECISION, VERSION_SCALE, col};
#[derive(Debug, Clone)]
pub struct StoredSeries {
pub series: MeasurementSeries,
pub sparte: Sparte,
pub unit: MeasurementUnit,
pub version: ScopedVersion,
pub recorded_at: OffsetDateTime,
pub extra: BTreeMap<String, ScalarValue>,
}
impl StoredSeries {
pub fn new(
series: MeasurementSeries,
version: ScopedVersion,
recorded_at: OffsetDateTime,
) -> Self {
Self::of(Sparte::Strom, series, version, recorded_at)
}
pub fn of(
sparte: Sparte,
series: MeasurementSeries,
version: ScopedVersion,
recorded_at: OffsetDateTime,
) -> Self {
Self {
series,
sparte,
unit: sparte.billing_unit(),
version,
recorded_at,
extra: BTreeMap::new(),
}
}
pub fn in_unit(mut self, unit: MeasurementUnit) -> Self {
self.unit = unit;
self
}
pub fn with_extra(mut self, name: impl Into<String>, value: ScalarValue) -> Self {
self.extra.insert(name.into(), value);
self
}
}
#[derive(Debug, Clone)]
pub struct StoredReadings {
pub malo_id: MaloId,
pub melo_id: Option<MeloId>,
pub obis_code: ObisCode,
pub readings: Vec<metering::reading::MeterReading>,
pub cadence: Option<metering::resolution::IntervalResolution>,
pub source: MeasurementSource,
pub provenance: Vec<ProvenanceEntry>,
pub sparte: Sparte,
pub unit: MeasurementUnit,
pub version: ScopedVersion,
pub recorded_at: OffsetDateTime,
pub extra: BTreeMap<String, ScalarValue>,
}
impl StoredReadings {
pub fn new(
malo_id: MaloId,
obis_code: ObisCode,
sparte: Sparte,
readings: Vec<metering::reading::MeterReading>,
source: MeasurementSource,
version: ScopedVersion,
recorded_at: OffsetDateTime,
) -> Self {
Self {
malo_id,
melo_id: None,
obis_code,
readings,
cadence: None,
source,
provenance: Vec::new(),
sparte,
unit: sparte.billing_unit(),
version,
recorded_at,
extra: BTreeMap::new(),
}
}
#[must_use]
pub fn with_melo_id(mut self, melo_id: MeloId) -> Self {
self.melo_id = Some(melo_id);
self
}
#[must_use]
pub fn in_unit(mut self, unit: MeasurementUnit) -> Self {
self.unit = unit;
self
}
#[must_use]
pub fn at_cadence(mut self, cadence: metering::resolution::IntervalResolution) -> Self {
self.cadence = Some(cadence);
self
}
#[must_use]
pub fn with_extra(mut self, name: impl Into<String>, value: ScalarValue) -> Self {
self.extra.insert(name.into(), value);
self
}
}
fn check_unit(sparte: Sparte, unit: MeasurementUnit, malo_id: &MaloId) -> Result<()> {
if unit == sparte.measured_unit() || unit == sparte.billing_unit() {
return Ok(());
}
Err(Error::encode(
col::UNIT,
format!(
"{malo_id}: {sparte} is measured in {} and billed in {}, so it cannot be \
stored as {unit}",
sparte.measured_unit(),
sparte.billing_unit(),
),
))
}
fn decode_malo(raw: &str) -> Result<MaloId> {
raw.parse::<MaloId>()
.map_err(|e| Error::decode(col::MALO_ID, format!("{raw:?}: {e}")))
}
fn decode_melo(raw: &str) -> Result<MeloId> {
raw.parse::<MeloId>()
.map_err(|e| Error::decode(col::MELO_ID, format!("{raw:?}: {e}")))
}
fn decode_code<T>(column: &str, raw: &str) -> Result<T>
where
T: std::str::FromStr + std::fmt::Display,
T::Err: std::fmt::Display,
{
let parsed: T = raw
.parse()
.map_err(|e| Error::decode(column, format!("{raw:?}: {e}")))?;
let canonical = parsed.to_string();
if canonical != raw {
return Err(Error::decode(
column,
format!(
"{raw:?} is an accepted input spelling but not the canonical one — storage \
holds {canonical:?} and only {canonical:?}, because this column is a \
grouping key and two spellings of one value are two rows. Rewrite the \
row, or write it through this crate"
),
));
}
Ok(parsed)
}
fn decode_resolution(raw: &str) -> Result<metering::resolution::IntervalResolution> {
let parsed: metering::resolution::IntervalResolution = raw
.parse()
.map_err(|e| Error::decode(col::RESOLUTION, format!("{raw:?}: {e}")))?;
let canonical = parsed.to_iso8601();
if canonical != raw {
return Err(Error::decode(
col::RESOLUTION,
format!(
"{raw:?} is an accepted spelling of {canonical:?} but not the canonical one — \
storage holds one spelling per grid, because completeness groups by this \
column and two spellings are two rows"
),
));
}
Ok(parsed)
}
fn to_scaled_i128(value: Decimal) -> Result<i128> {
let scale = u32::try_from(VALUE_SCALE).expect("scale is non-negative");
let mut v = value.normalize();
if v.scale() > scale {
return Err(Error::encode(
col::VALUE,
format!(
"{value} needs {} decimal places, storage holds {VALUE_SCALE}",
v.scale()
),
));
}
v.rescale(scale);
let mantissa = v.mantissa();
let limit = 10i128.pow(u32::from(VALUE_PRECISION));
if mantissa >= limit || mantissa <= -limit {
return Err(Error::encode(
col::VALUE,
format!("{value} exceeds Decimal128({VALUE_PRECISION},{VALUE_SCALE})"),
));
}
Ok(mantissa)
}
fn from_scaled_i128(raw: i128) -> Decimal {
Decimal::from_i128_with_scale(
raw,
u32::try_from(VALUE_SCALE).expect("scale is non-negative"),
)
}
fn encode_source(source: &MeasurementSource) -> Result<(String, String)> {
let payload = serde_json::to_value(source)?;
let kind = match &payload {
serde_json::Value::Object(fields) if fields.len() == 1 => {
fields.keys().next().expect("length checked").clone()
}
serde_json::Value::String(tag) => tag.clone(),
other => {
return Err(Error::encode(
col::SOURCE_KIND,
format!(
"{} does not serialise to a tagged form, so it has no \
discriminant to store: {other}",
std::any::type_name::<MeasurementSource>()
),
));
}
};
Ok((kind, serde_json::to_string(&payload)?))
}
fn encode_provenance(trail: &[ProvenanceEntry]) -> Result<String> {
serde_json::to_string(trail).map_err(|e| Error::encode(col::PROVENANCE, e.to_string()))
}
fn decode_provenance(raw: &str) -> Result<Vec<ProvenanceEntry>> {
serde_json::from_str(raw).map_err(|e| Error::decode(col::PROVENANCE, format!("{raw:?}: {e}")))
}
fn decode_source(kind: &str, detail: Option<&str>) -> Result<MeasurementSource> {
let detail = detail.ok_or_else(|| {
Error::decode(
col::SOURCE_DETAIL,
format!("missing payload for kind {kind:?}"),
)
})?;
let source: MeasurementSource = serde_json::from_str(detail)?;
let (round_tripped, _) = encode_source(&source)?;
if round_tripped != kind {
return Err(Error::decode(
col::SOURCE_KIND,
format!("discriminant {kind:?} disagrees with payload {round_tripped:?}"),
));
}
Ok(source)
}
fn obis_for(series: &MeasurementSeries, interval: &MeterInterval) -> Result<ObisCode> {
interval.obis_code.or(series.obis_code).ok_or_else(|| {
Error::encode(
col::OBIS_CODE,
format!(
"neither interval nor series {} carries an OBIS code",
series.malo_id
),
)
})
}
pub fn to_record_batch(stored: &[StoredSeries]) -> Result<RecordBatch> {
to_record_batch_with(stored, &[])
}
#[derive(Default)]
struct RowColumns {
malo: Vec<String>,
melo: Vec<Option<String>>,
obis: Vec<String>,
sparte: Vec<&'static str>,
from: Vec<i64>,
to: Vec<Option<i64>>,
value: Vec<i128>,
unit: Vec<&'static str>,
quality: Vec<&'static str>,
resolution: Vec<Option<String>>,
source_kind: Vec<String>,
source_detail: Vec<Option<String>>,
provenance: Vec<Option<String>>,
version: Vec<i128>,
version_scope: Vec<String>,
recorded_at: Vec<i64>,
balancing_day: Vec<i32>,
extra: Vec<Vec<ScalarValue>>,
}
struct SeriesFields {
malo: String,
melo: Option<String>,
sparte: &'static str,
unit: &'static str,
resolution: Option<String>,
source_kind: String,
source_detail: String,
provenance: String,
version: i128,
version_scope: String,
recorded_at: i64,
extra: Vec<ScalarValue>,
}
impl RowColumns {
fn with_extra(extra: &[Field]) -> Self {
Self {
extra: vec![Vec::new(); extra.len()],
..Default::default()
}
}
#[allow(clippy::too_many_arguments)]
fn push(
&mut self,
fields: &SeriesFields,
obis: String,
from: i64,
to: Option<i64>,
value: i128,
quality: &'static str,
balancing_day: i32,
) {
self.malo.push(fields.malo.clone());
self.melo.push(fields.melo.clone());
self.obis.push(obis);
self.sparte.push(fields.sparte);
self.from.push(from);
self.to.push(to);
self.value.push(value);
self.unit.push(fields.unit);
self.quality.push(quality);
self.resolution.push(fields.resolution.clone());
self.source_kind.push(fields.source_kind.clone());
self.source_detail.push(Some(fields.source_detail.clone()));
self.provenance.push(Some(fields.provenance.clone()));
self.version.push(fields.version);
self.version_scope.push(fields.version_scope.clone());
self.recorded_at.push(fields.recorded_at);
self.balancing_day.push(balancing_day);
for (column, value) in self.extra.iter_mut().zip(&fields.extra) {
column.push(value.clone());
}
}
fn finish(self, extra: &[Field]) -> Result<RecordBatch> {
let tz: Arc<str> = "UTC".into();
let mut columns: Vec<ArrayRef> = vec![
Arc::new(StringArray::from(self.malo)),
Arc::new(StringArray::from(self.melo)),
Arc::new(StringArray::from(self.obis)),
Arc::new(StringArray::from(self.sparte)),
Arc::new(TimestampMicrosecondArray::from(self.from).with_timezone(tz.clone())),
Arc::new(TimestampMicrosecondArray::from(self.to).with_timezone(tz.clone())),
Arc::new(
Decimal128Array::from(self.value)
.with_precision_and_scale(VALUE_PRECISION, VALUE_SCALE)?,
),
Arc::new(StringArray::from(self.unit)),
Arc::new(StringArray::from(self.quality)),
Arc::new(StringArray::from(self.resolution)),
Arc::new(StringArray::from(self.source_kind)),
Arc::new(StringArray::from(self.source_detail)),
Arc::new(StringArray::from(self.provenance)),
Arc::new(
Decimal128Array::from(self.version)
.with_precision_and_scale(VERSION_PRECISION, VERSION_SCALE)?,
),
Arc::new(StringArray::from(self.version_scope)),
Arc::new(TimestampMicrosecondArray::from(self.recorded_at).with_timezone(tz)),
Arc::new(crate::arrow::array::Date32Array::from(self.balancing_day)),
];
for (field, values) in extra.iter().zip(self.extra) {
columns.push(match values.is_empty() {
true => crate::arrow::array::new_empty_array(field.data_type()),
false => ScalarValue::iter_to_array(values)?,
});
}
Ok(RecordBatch::try_new(
schema::storage_schema(extra),
columns,
)?)
}
}
fn resolve_extra(
extra: &[Field],
supplied: &BTreeMap<String, ScalarValue>,
) -> Result<Vec<ScalarValue>> {
let mut out = Vec::with_capacity(extra.len());
for field in extra {
let value = match supplied.get(field.name()) {
Some(value) => value.clone(),
None if field.is_nullable() => ScalarValue::try_from(field.data_type())?,
None => {
return Err(Error::encode(
field.name(),
"declared column has no value on this delivery, and the \
column is not nullable"
.to_string(),
));
}
};
if value.data_type() != *field.data_type() {
return Err(Error::encode(
field.name(),
format!(
"declared as {:?} but value is {:?}",
field.data_type(),
value.data_type()
),
));
}
if value.is_null() && !field.is_nullable() {
return Err(Error::encode(
field.name(),
"value is null but the column is not nullable".to_string(),
));
}
out.push(value);
}
Ok(out)
}
fn check_scope(version: &ScopedVersion, at: OffsetDateTime, sparte: Sparte) -> Result<()> {
if version.scope().covers(at, sparte) {
return Ok(());
}
Err(Error::encode(
col::VERSION_SCOPE,
format!(
"value at {at} is not in scope {} for {sparte} — the scope must be \
derived from the value's own Bilanzierungsmonat, not the delivery \
month, and for gas that month is cut at 06:00 local rather than at \
midnight. VersionScope::for_interval derives it",
version.scope(),
),
))
}
pub fn to_record_batch_with(stored: &[StoredSeries], extra: &[Field]) -> Result<RecordBatch> {
let mut columns = RowColumns::with_extra(extra);
for s in stored {
check_unit(s.sparte, s.unit, &s.series.malo_id)?;
let (kind, detail) = encode_source(&s.series.source)?;
if s.series.intervals.is_empty() {
continue;
}
let fields = SeriesFields {
malo: s.series.malo_id.to_string(),
melo: s.series.melo_id.as_ref().map(MeloId::to_string),
sparte: s.sparte.as_str(),
unit: s.unit.as_str(),
resolution: s.series.resolution.map(|r| r.to_iso8601()),
source_kind: kind,
source_detail: detail,
provenance: encode_provenance(&s.series.provenance)?,
version: s.version.version().to_i128(),
version_scope: s.version.scope().as_str().to_string(),
recorded_at: schema::micros(s.recorded_at),
extra: resolve_extra(extra, &s.extra)?,
};
for interval in &s.series.intervals {
check_scope(&s.version, interval.from, s.sparte)?;
if interval.to <= interval.from {
return Err(Error::encode(
col::TO,
format!(
"interval end {} is not after start {} for {}",
interval.to, interval.from, s.series.malo_id
),
));
}
columns.push(
&fields,
obis_for(&s.series, interval)?.to_string(),
schema::micros(interval.from),
Some(schema::micros(interval.to)),
to_scaled_i128(interval.value)?,
interval.quality.as_str(),
schema::date32(crate::planner::balancing_day(interval.from, s.sparte)),
);
}
}
columns.finish(extra)
}
pub fn readings_to_record_batch(stored: &[StoredReadings]) -> Result<RecordBatch> {
readings_to_record_batch_with(stored, &[])
}
pub fn readings_to_record_batch_with(
stored: &[StoredReadings],
extra: &[Field],
) -> Result<RecordBatch> {
let mut columns = RowColumns::with_extra(extra);
for s in stored {
check_unit(s.sparte, s.unit, &s.malo_id)?;
let (kind, detail) = encode_source(&s.source)?;
if s.readings.is_empty() {
continue;
}
let obis = s.obis_code.to_string();
let fields = SeriesFields {
malo: s.malo_id.to_string(),
melo: s.melo_id.as_ref().map(MeloId::to_string),
sparte: s.sparte.as_str(),
unit: s.unit.as_str(),
resolution: s.cadence.map(|r| r.to_iso8601()),
source_kind: kind,
source_detail: detail,
provenance: encode_provenance(&s.provenance)?,
version: s.version.version().to_i128(),
version_scope: s.version.scope().as_str().to_string(),
recorded_at: schema::micros(s.recorded_at),
extra: resolve_extra(extra, &s.extra)?,
};
for reading in &s.readings {
check_scope(&s.version, reading.at, s.sparte)?;
columns.push(
&fields,
reading
.obis_code
.map_or_else(|| obis.clone(), |c| c.to_string()),
schema::micros(reading.at),
None,
to_scaled_i128(reading.value)?,
reading.quality.as_str(),
schema::date32(crate::planner::balancing_day(reading.at, s.sparte)),
);
}
}
columns.finish(extra)
}
pub fn sorted_for_storage(batch: &RecordBatch) -> Result<RecordBatch> {
use crate::arrow::compute::{SortColumn, lexsort_to_indices, take};
if batch.num_rows() < 2 {
return Ok(batch.clone());
}
let mut columns = Vec::with_capacity(schema::SORT_COLUMNS.len());
for name in schema::SORT_COLUMNS {
columns.push(SortColumn {
values: batch
.column_by_name(name)
.ok_or_else(|| Error::encode(name, "declared a sort column but not in the batch"))?
.clone(),
options: None,
});
}
let indices = lexsort_to_indices(&columns, None)?;
let sorted = batch
.columns()
.iter()
.map(|c| take(c, &indices, None))
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(RecordBatch::try_new(batch.schema(), sorted)?)
}
pub fn distinct_malo_ids(batches: &[RecordBatch]) -> u64 {
let mut seen = std::collections::HashSet::new();
for batch in batches {
if let Some(column) = batch.column_by_name(col::MALO_ID)
&& let Some(values) = column.as_any().downcast_ref::<StringArray>()
{
for i in 0..values.len() {
if !values.is_null(i) {
seen.insert(values.value(i).to_string());
}
}
}
}
seen.len() as u64
}
pub(crate) fn column<'a, T: Array + 'static>(batch: &'a RecordBatch, name: &str) -> Result<&'a T> {
batch
.column_by_name(name)
.ok_or_else(|| Error::decode(name, "column missing"))?
.as_any()
.downcast_ref::<T>()
.ok_or_else(|| Error::decode(name, "unexpected array type"))
}
pub const INTERVAL_COLUMNS: [&str; 5] = [
col::FROM,
col::TO,
col::VALUE,
col::QUALITY,
col::BALANCING_DAY,
];
pub fn from_record_batch(batch: &RecordBatch) -> Result<Vec<StoredSeries>> {
let core = schema::storage_schema(&[]);
let extra_names: Vec<String> = batch
.schema()
.fields()
.iter()
.filter(|f| core.field_with_name(f.name()).is_err())
.map(|f| f.name().clone())
.collect();
let malo = column::<StringArray>(batch, col::MALO_ID)?;
let melo = column::<StringArray>(batch, col::MELO_ID)?;
let obis = column::<StringArray>(batch, col::OBIS_CODE)?;
let sparte = column::<StringArray>(batch, col::SPARTE)?;
let from = column::<TimestampMicrosecondArray>(batch, col::FROM)?;
let to = column::<TimestampMicrosecondArray>(batch, col::TO)?;
let value = column::<Decimal128Array>(batch, col::VALUE)?;
let unit = column::<StringArray>(batch, col::UNIT)?;
let quality = column::<StringArray>(batch, col::QUALITY)?;
let resolution = column::<StringArray>(batch, col::RESOLUTION)?;
let source_kind = column::<StringArray>(batch, col::SOURCE_KIND)?;
let source_detail = column::<StringArray>(batch, col::SOURCE_DETAIL)?;
let provenance = column::<StringArray>(batch, col::PROVENANCE)?;
let version = column::<Decimal128Array>(batch, col::VERSION)?;
let version_scope = column::<StringArray>(batch, col::VERSION_SCOPE)?;
let recorded_at = column::<TimestampMicrosecondArray>(batch, col::RECORDED_AT)?;
let key_columns: Vec<crate::arrow::array::ArrayRef> = batch
.schema()
.fields()
.iter()
.enumerate()
.filter(|(_, f)| !INTERVAL_COLUMNS.contains(&f.name().as_str()))
.map(|(i, _)| batch.column(i).clone())
.collect();
let starts: std::collections::HashSet<usize> = if batch.num_rows() == 0 {
Default::default()
} else {
crate::arrow::compute::partition(&key_columns)?
.ranges()
.iter()
.map(|r| r.start)
.collect()
};
let mut out: Vec<StoredSeries> = Vec::new();
for i in 0..batch.num_rows() {
if starts.contains(&i) {
let source = decode_source(
source_kind.value(i),
(!source_detail.is_null(i)).then(|| source_detail.value(i)),
)?;
let res = match resolution.is_null(i) {
true => None,
false => Some(decode_resolution(resolution.value(i))?),
};
let prov: Vec<ProvenanceEntry> = if provenance.is_null(i) {
Vec::new()
} else {
decode_provenance(provenance.value(i))?
};
let mut extra = BTreeMap::new();
for name in &extra_names {
let column = batch
.column_by_name(name)
.ok_or_else(|| Error::decode(name, "column vanished"))?;
extra.insert(name.clone(), ScalarValue::try_from_array(column, i)?);
}
let sparte: Sparte = decode_code(col::SPARTE, sparte.value(i))?;
let unit: MeasurementUnit = decode_code(col::UNIT, unit.value(i))?;
let malo_id = decode_malo(malo.value(i))?;
let melo_id = match melo.is_null(i) {
true => None,
false => Some(decode_melo(melo.value(i))?),
};
check_unit(sparte, unit, &malo_id)?;
out.push(StoredSeries {
extra,
sparte,
unit,
series: MeasurementSeries {
malo_id,
melo_id,
obis_code: Some(decode_code(col::OBIS_CODE, obis.value(i))?),
resolution: res,
source,
intervals: Vec::new(),
provenance: prov,
},
version: ScopedVersion::new(
VersionScope::parse(version_scope.value(i))?,
Version::from_i128(version.value(i))?,
),
recorded_at: schema::instant(recorded_at.value(i))?,
});
}
if to.is_null(i) {
return Err(Error::decode(
col::TO,
format!(
"row {i} has no span end, so it is a register reading rather than an \
interval and its value means something else. Use \
readings_from_record_batch for a point table"
),
));
}
let series = out.last_mut().expect("pushed above");
series.series.intervals.push(MeterInterval {
from: schema::instant(from.value(i))?,
to: schema::instant(to.value(i))?,
value: from_scaled_i128(value.value(i)),
quality: decode_code(col::QUALITY, quality.value(i))?,
obis_code: Some(decode_code(col::OBIS_CODE, obis.value(i))?),
});
}
Ok(out)
}
pub fn readings_from_record_batch(batch: &RecordBatch) -> Result<Vec<StoredReadings>> {
let core = schema::storage_schema(&[]);
let extra_names: Vec<String> = batch
.schema()
.fields()
.iter()
.filter(|f| core.field_with_name(f.name()).is_err())
.map(|f| f.name().clone())
.collect();
let malo = column::<StringArray>(batch, col::MALO_ID)?;
let melo = column::<StringArray>(batch, col::MELO_ID)?;
let obis = column::<StringArray>(batch, col::OBIS_CODE)?;
let sparte = column::<StringArray>(batch, col::SPARTE)?;
let from = column::<TimestampMicrosecondArray>(batch, col::FROM)?;
let to = column::<TimestampMicrosecondArray>(batch, col::TO)?;
let value = column::<Decimal128Array>(batch, col::VALUE)?;
let unit = column::<StringArray>(batch, col::UNIT)?;
let quality = column::<StringArray>(batch, col::QUALITY)?;
let resolution = column::<StringArray>(batch, col::RESOLUTION)?;
let source_kind = column::<StringArray>(batch, col::SOURCE_KIND)?;
let source_detail = column::<StringArray>(batch, col::SOURCE_DETAIL)?;
let provenance = column::<StringArray>(batch, col::PROVENANCE)?;
let version = column::<Decimal128Array>(batch, col::VERSION)?;
let version_scope = column::<StringArray>(batch, col::VERSION_SCOPE)?;
let recorded_at = column::<TimestampMicrosecondArray>(batch, col::RECORDED_AT)?;
let key_columns: Vec<crate::arrow::array::ArrayRef> = batch
.schema()
.fields()
.iter()
.enumerate()
.filter(|(_, f)| !INTERVAL_COLUMNS.contains(&f.name().as_str()))
.map(|(i, _)| batch.column(i).clone())
.collect();
let starts: std::collections::HashSet<usize> = if batch.num_rows() == 0 {
Default::default()
} else {
crate::arrow::compute::partition(&key_columns)?
.ranges()
.iter()
.map(|r| r.start)
.collect()
};
let mut out: Vec<StoredReadings> = Vec::new();
for i in 0..batch.num_rows() {
if !to.is_null(i) {
return Err(Error::decode(
col::TO,
format!(
"row {i} carries a span end, so it is an interval and its value is \
energy over that span rather than a register reading. A point table \
stores no {:?}; use from_record_batch for an interval table",
col::TO
),
));
}
if starts.contains(&i) {
let source = decode_source(
source_kind.value(i),
(!source_detail.is_null(i)).then(|| source_detail.value(i)),
)?;
let cadence = match resolution.is_null(i) {
true => None,
false => Some(decode_resolution(resolution.value(i))?),
};
let prov: Vec<ProvenanceEntry> = match provenance.is_null(i) {
true => Vec::new(),
false => decode_provenance(provenance.value(i))?,
};
let mut extra = BTreeMap::new();
for name in &extra_names {
let column = batch
.column_by_name(name)
.ok_or_else(|| Error::decode(name, "column vanished"))?;
extra.insert(name.clone(), ScalarValue::try_from_array(column, i)?);
}
let sparte: Sparte = decode_code(col::SPARTE, sparte.value(i))?;
let unit: MeasurementUnit = decode_code(col::UNIT, unit.value(i))?;
let malo_id = decode_malo(malo.value(i))?;
check_unit(sparte, unit, &malo_id)?;
out.push(StoredReadings {
malo_id,
melo_id: match melo.is_null(i) {
true => None,
false => Some(decode_melo(melo.value(i))?),
},
obis_code: decode_code(col::OBIS_CODE, obis.value(i))?,
readings: Vec::new(),
cadence,
source,
provenance: prov,
sparte,
unit,
version: ScopedVersion::new(
VersionScope::parse(version_scope.value(i))?,
Version::from_i128(version.value(i))?,
),
recorded_at: schema::instant(recorded_at.value(i))?,
extra,
});
}
let delivery = out.last_mut().expect("pushed above");
delivery.readings.push(metering::reading::MeterReading {
at: schema::instant(from.value(i))?,
value: from_scaled_i128(value.value(i)),
quality: decode_code(col::QUALITY, quality.value(i))?,
obis_code: Some(decode_code(col::OBIS_CODE, obis.value(i))?),
});
}
Ok(out)
}
pub fn canonical_obis(code: &str) -> Result<String> {
ObisCode::normalize(code).map_err(|e| Error::encode(col::OBIS_CODE, format!("{code:?}: {e}")))
}
pub fn parse_malo<M>(malo_id: M) -> Result<MaloId>
where
M: TryInto<MaloId>,
M::Error: std::fmt::Display,
{
malo_id
.try_into()
.map_err(|e| Error::encode(col::MALO_ID, e.to_string()))
}
pub fn extra_field(name: &str, ty: crate::arrow::datatypes::DataType) -> Field {
Field::new(name, ty, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::arrow::datatypes::DataType;
use metering::QualityFlag;
use metering::resolution::IntervalResolution;
use time::macros::datetime;
fn batch_with_malos(ids: &[&str]) -> RecordBatch {
let schema = schema::storage_schema(&[]);
let n = ids.len();
let columns: Vec<ArrayRef> = schema
.fields()
.iter()
.map(|f| match f.data_type() {
crate::arrow::datatypes::DataType::Utf8 => {
if f.name() == col::MALO_ID {
Arc::new(StringArray::from(ids.to_vec())) as _
} else {
Arc::new(StringArray::from(vec!["x"; n])) as _
}
}
crate::arrow::datatypes::DataType::Timestamp(_, _) => {
Arc::new(TimestampMicrosecondArray::from(vec![0i64; n]).with_timezone("UTC"))
as _
}
crate::arrow::datatypes::DataType::Date32 => {
Arc::new(crate::arrow::array::Date32Array::from(vec![0i32; n])) as _
}
crate::arrow::datatypes::DataType::Decimal128(p, s) => Arc::new(
Decimal128Array::from(vec![0i128; n])
.with_precision_and_scale(*p, *s)
.unwrap(),
) as _,
other => panic!("unhandled {other:?}"),
})
.collect();
RecordBatch::try_new(schema, columns).unwrap()
}
#[test]
fn distinct_malo_ids_counts_uniques_not_rows() {
let b = batch_with_malos(&["a", "a", "b", "c", "c", "c"]);
assert_eq!(distinct_malo_ids(&[b]), 3);
}
#[test]
fn distinct_malo_ids_spans_batches() {
let a = batch_with_malos(&["a", "b"]);
let b = batch_with_malos(&["b", "c"]);
assert_eq!(distinct_malo_ids(&[a, b]), 3);
}
#[test]
fn sorting_for_storage_produces_the_order_the_footer_declares() {
let batch = batch_with_malos(&["b", "a", "b", "a"]);
let starts = TimestampMicrosecondArray::from(vec![20i64, 30, 10, 5]).with_timezone("UTC");
let index = batch.schema().index_of(col::FROM).unwrap();
let mut columns = batch.columns().to_vec();
columns[index] = Arc::new(starts);
let batch = RecordBatch::try_new(batch.schema(), columns).unwrap();
let sorted = sorted_for_storage(&batch).unwrap();
let malo = column::<StringArray>(&sorted, col::MALO_ID).unwrap();
let from = column::<TimestampMicrosecondArray>(&sorted, col::FROM).unwrap();
let observed: Vec<(&str, i64)> = (0..sorted.num_rows())
.map(|i| (malo.value(i), from.value(i)))
.collect();
assert_eq!(observed, vec![("a", 5), ("a", 30), ("b", 10), ("b", 20)]);
assert_eq!(sorted.schema(), batch.schema());
}
#[test]
fn sorting_for_storage_leaves_a_trivial_batch_alone() {
for ids in [vec![], vec!["a"]] {
let batch = batch_with_malos(&ids);
let sorted = sorted_for_storage(&batch).unwrap();
assert_eq!(sorted.num_rows(), batch.num_rows());
assert_eq!(sorted.schema(), batch.schema());
}
}
#[test]
fn distinct_malo_ids_of_nothing_is_zero() {
assert_eq!(distinct_malo_ids(&[]), 0);
}
const INGESTED_AT: OffsetDateTime = datetime!(2026-07-27 06:00 UTC);
fn scope_for(interval: OffsetDateTime) -> VersionScope {
VersionScope::for_interval("9900000000001", interval, Sparte::Strom).unwrap()
}
fn malo(s: &str) -> MaloId {
s.parse().expect("test MaLo-ID is well-formed")
}
fn source() -> MeasurementSource {
MeasurementSource::Mscons {
pid: 13_005,
message_ref: Some("MSG-1".to_string()),
sender_mp_id: "9900000000001".parse().expect("a valid Marktpartner-ID"),
}
}
fn series(intervals: Vec<MeterInterval>) -> StoredSeries {
let first_start = intervals.first().map(|i| i.from).unwrap_or(INGESTED_AT);
StoredSeries {
extra: BTreeMap::new(),
sparte: Sparte::Strom,
unit: MeasurementUnit::KiloWattHour,
series: {
let mut s = MeasurementSeries::new(
malo("12345678905"),
"1-0:1.8.0".parse().ok(),
intervals,
source(),
INGESTED_AT,
)
.with_melo_id("DE0001234567890123456789012345678".parse().unwrap());
s.resolution = Some(IntervalResolution::QuarterHour);
s
},
version: ScopedVersion::new(
scope_for(first_start),
Version::new(20_260_727_000_001).unwrap(),
),
recorded_at: datetime!(2026-07-27 10:00 UTC),
}
}
#[test]
fn a_nullable_extra_column_may_be_absent() {
use crate::arrow::array::Array;
let s = series(vec![quarter(
datetime!(2026-07-20 00:00 UTC),
"1.5",
QualityFlag::Measured,
)]);
let field = Field::new("bilanzkreis", DataType::Utf8, true);
let batch = to_record_batch_with(&[s], std::slice::from_ref(&field)).unwrap();
let column = batch.column_by_name("bilanzkreis").unwrap();
assert_eq!(column.null_count(), 1, "absent means null, not an error");
}
#[test]
fn a_batch_with_no_rows_keeps_its_declared_shape() {
let field = Field::new("tenant", DataType::Utf8, false);
let empty = to_record_batch_with(&[], std::slice::from_ref(&field)).unwrap();
assert_eq!(empty.num_rows(), 0);
assert_eq!(
empty.schema(),
schema::storage_schema(std::slice::from_ref(&field))
);
let no_intervals = series(vec![]);
let batch = to_record_batch_with(&[no_intervals], std::slice::from_ref(&field)).unwrap();
assert_eq!(batch.num_rows(), 0);
}
#[test]
fn a_non_nullable_extra_column_must_be_supplied() {
let s = series(vec![quarter(
datetime!(2026-07-20 00:00 UTC),
"1.5",
QualityFlag::Measured,
)]);
let field = Field::new("tenant", DataType::Utf8, false);
let err = to_record_batch_with(&[s], std::slice::from_ref(&field)).unwrap_err();
assert!(err.to_string().contains("tenant"), "{err}");
}
#[test]
fn an_explicit_null_in_a_non_nullable_column_is_named() {
let s = series(vec![quarter(
datetime!(2026-07-20 00:00 UTC),
"1.5",
QualityFlag::Measured,
)])
.with_extra("tenant", ScalarValue::Utf8(None));
let field = Field::new("tenant", DataType::Utf8, false);
let err = to_record_batch_with(&[s], std::slice::from_ref(&field)).unwrap_err();
assert!(err.to_string().contains("tenant"), "{err}");
assert!(err.to_string().contains("null"), "{err}");
}
fn quarter(from: OffsetDateTime, kwh: &str, q: QualityFlag) -> MeterInterval {
MeterInterval {
from,
to: from + time::Duration::minutes(15),
value: kwh.parse().unwrap(),
quality: q,
obis_code: "1-0:1.8.0".parse().ok(),
}
}
#[test]
fn round_trip_preserves_a_simple_series() {
let base = datetime!(2026-03-01 00:00 UTC);
let input = series(vec![
quarter(base, "1.234567", QualityFlag::Measured),
quarter(
base + time::Duration::minutes(15),
"0.5",
QualityFlag::Estimated,
),
]);
let batch = to_record_batch(std::slice::from_ref(&input)).unwrap();
assert_eq!(batch.num_rows(), 2);
let out = from_record_batch(&batch).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].series.malo_id, input.series.malo_id);
assert_eq!(out[0].series.melo_id, input.series.melo_id);
assert_eq!(out[0].series.resolution, input.series.resolution);
assert_eq!(out[0].version, input.version);
assert_eq!(out[0].recorded_at, input.recorded_at);
assert_eq!(out[0].series.intervals.len(), 2);
}
fn with_column(batch: &RecordBatch, name: &str, values: Vec<&str>) -> RecordBatch {
let index = batch.schema().index_of(name).unwrap();
let mut columns = batch.columns().to_vec();
columns[index] = Arc::new(StringArray::from(values));
RecordBatch::try_new(batch.schema(), columns).unwrap()
}
#[test]
fn a_non_canonical_obis_code_is_refused_on_decode() {
let input = series(vec![quarter(
datetime!(2026-03-01 00:00 UTC),
"1.5",
QualityFlag::Measured,
)]);
let batch = to_record_batch(&[input]).unwrap();
assert!(
from_record_batch(&batch).is_ok(),
"the canonical form decodes"
);
for spelling in ["1-0:1.8.0*255", "01-0:1.8.0", " 1-0:1.8.0"] {
let err = from_record_batch(&with_column(&batch, col::OBIS_CODE, vec![spelling]))
.unwrap_err()
.to_string();
assert!(
err.contains("canonical"),
"{spelling:?} was accepted or refused for the wrong reason: {err}"
);
}
}
#[test]
fn round_trip_preserves_decimal_precision_exactly() {
let base = datetime!(2026-03-01 00:00 UTC);
for raw in ["0.000001", "123456789012.345678", "-0.000001", "0"] {
let input = series(vec![quarter(base, raw, QualityFlag::Measured)]);
let batch = to_record_batch(std::slice::from_ref(&input)).unwrap();
let out = from_record_batch(&batch).unwrap();
let got = out[0].series.intervals[0].value;
assert_eq!(
got,
raw.parse::<Decimal>().unwrap(),
"value {raw} did not round-trip exactly"
);
}
}
#[test]
fn encoding_rejects_values_needing_more_precision_than_it_can_hold() {
let base = datetime!(2026-03-01 00:00 UTC);
let input = series(vec![quarter(base, "0.0000001", QualityFlag::Measured)]);
assert!(matches!(
to_record_batch(&[input]),
Err(Error::Encode { .. })
));
}
#[test]
fn round_trip_preserves_every_quality_flag() {
let base = datetime!(2026-03-01 00:00 UTC);
let flags = [
QualityFlag::Measured,
QualityFlag::Estimated,
QualityFlag::Substituted,
QualityFlag::Calculated,
QualityFlag::Corrected,
QualityFlag::Preliminary,
QualityFlag::Faulty,
QualityFlag::Unknown,
];
let intervals: Vec<_> = flags
.iter()
.enumerate()
.map(|(i, &q)| quarter(base + time::Duration::minutes(15 * i as i64), "1.0", q))
.collect();
let batch = to_record_batch(&[series(intervals)]).unwrap();
let out = from_record_batch(&batch).unwrap();
let got: Vec<_> = out[0].series.intervals.iter().map(|i| i.quality).collect();
assert_eq!(got, flags);
}
#[test]
fn round_trip_preserves_irregular_interval_boundaries() {
let dst_back = datetime!(2026-10-25 00:00 UTC);
let odd = MeterInterval {
from: dst_back,
to: dst_back + time::Duration::minutes(37), value: "2.5".parse().unwrap(),
quality: QualityFlag::Measured,
obis_code: "1-0:1.8.0".parse().ok(),
};
let batch = to_record_batch(&[series(vec![odd.clone()])]).unwrap();
let out = from_record_batch(&batch).unwrap();
assert_eq!(out[0].series.intervals[0].from, odd.from);
assert_eq!(out[0].series.intervals[0].to, odd.to);
assert_eq!(
out[0].series.intervals[0].to - out[0].series.intervals[0].from,
time::Duration::minutes(37)
);
}
#[test]
fn encoding_rejects_a_scope_that_does_not_cover_the_interval() {
let january = datetime!(2026-01-15 00:00 UTC);
let mut input = series(vec![quarter(january, "1.0", QualityFlag::Measured)]);
input.version = ScopedVersion::new(
VersionScope::new("9900000000001", 2026, 7).unwrap(),
Version::new(20_260_715_000_002).unwrap(),
);
assert!(matches!(
to_record_batch(std::slice::from_ref(&input)),
Err(Error::Encode { .. })
));
input.version = ScopedVersion::new(
VersionScope::for_interval("9900000000001", january, Sparte::Strom).unwrap(),
Version::new(20_260_115_000_001).unwrap(),
);
assert!(to_record_batch(&[input]).is_ok());
}
#[test]
fn a_correction_derives_the_same_scope_as_the_value_it_corrects() {
let interval = datetime!(2026-03-01 00:00 UTC);
let scope = VersionScope::for_interval("9900000000001", interval, Sparte::Strom).unwrap();
let original = ScopedVersion::new(scope.clone(), Version::new(20_260_301_000_001).unwrap());
let correction = ScopedVersion::new(scope, Version::new(20_260_415_000_002).unwrap());
assert!(correction.supersedes(&original).unwrap());
}
#[test]
fn encoding_rejects_non_positive_intervals() {
let base = datetime!(2026-03-01 00:00 UTC);
let bad = MeterInterval {
from: base,
to: base,
value: Decimal::ONE,
quality: QualityFlag::Measured,
obis_code: "1-0:1.8.0".parse().ok(),
};
assert!(to_record_batch(&[series(vec![bad])]).is_err());
}
#[test]
fn round_trip_preserves_source_variant_payload() {
let base = datetime!(2026-03-01 00:00 UTC);
let mut input = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
input.series.source = MeasurementSource::SmgwDirectPush {
device_id: "SMGW-42".to_string(),
session_id: "S-1".to_string(),
};
let batch = to_record_batch(std::slice::from_ref(&input)).unwrap();
let out = from_record_batch(&batch).unwrap();
match &out[0].series.source {
MeasurementSource::SmgwDirectPush {
device_id,
session_id,
} => {
assert_eq!(device_id, "SMGW-42");
assert_eq!(session_id, "S-1");
}
other => panic!("source variant lost: {other:?}"),
}
}
#[test]
fn two_deliveries_sharing_a_version_do_not_merge_into_one_source() {
let base = datetime!(2026-03-01 00:00 UTC);
let mut mscons = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
mscons.series.source = MeasurementSource::Mscons {
pid: 13_005,
message_ref: None,
sender_mp_id: "9900000000001".parse().expect("a valid Marktpartner-ID"),
};
let mut gateway = series(vec![quarter(
base + time::Duration::minutes(15),
"2.0",
QualityFlag::Measured,
)]);
gateway.series.source = MeasurementSource::SmgwDirectPush {
device_id: "SMGW-42".to_string(),
session_id: "S-1".to_string(),
};
assert_eq!(mscons.version, gateway.version);
let batch = to_record_batch(&[mscons, gateway]).unwrap();
let out = from_record_batch(&batch).unwrap();
assert_eq!(out.len(), 2, "two deliveries, two series");
assert!(matches!(
out[0].series.source,
MeasurementSource::Mscons { .. }
));
assert!(matches!(
out[1].series.source,
MeasurementSource::SmgwDirectPush { .. }
));
}
#[test]
fn a_decoded_series_is_one_channel() {
let base = datetime!(2026-03-01 00:00 UTC);
let mut a = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
a.series.obis_code = "1-0:1.8.0".parse().ok();
a.series.intervals[0].obis_code = "1-0:1.8.0".parse().ok();
let mut b = series(vec![quarter(base, "2.0", QualityFlag::Measured)]);
b.series.obis_code = "1-0:2.8.0".parse().ok();
b.series.intervals[0].obis_code = "1-0:2.8.0".parse().ok();
let batch = to_record_batch(&[a, b]).unwrap();
let out = from_record_batch(&batch).unwrap();
assert_eq!(out.len(), 2);
for decoded in &out {
let channel = decoded.series.obis_code.expect("a channel");
assert!(
decoded
.series
.intervals
.iter()
.all(|i| i.obis_code == Some(channel)),
"every interval must be on the channel the series names"
);
}
}
#[test]
fn source_kind_column_is_the_payloads_own_tag() {
let base = datetime!(2026-03-01 00:00 UTC);
let input = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
let batch = to_record_batch(&[input]).unwrap();
let column = |name: &str| {
batch
.column_by_name(name)
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.value(0)
.to_string()
};
let kind = column(col::SOURCE_KIND);
assert_eq!(kind, "MSCONS", "the tag `metering` itself writes");
let payload: serde_json::Value = serde_json::from_str(&column(col::SOURCE_DETAIL)).unwrap();
assert_eq!(
payload.as_object().unwrap().keys().collect::<Vec<_>>(),
vec![&kind],
"the discriminant column must be the payload's own key"
);
}
#[test]
fn the_stored_json_representation_is_pinned() {
let (kind, detail) = encode_source(&source()).unwrap();
assert_eq!(kind, "MSCONS");
assert_eq!(
detail,
r#"{"MSCONS":{"message_ref":"MSG-1","pid":13005,"sender_mp_id":"9900000000001"}}"#,
"the stored shape of MeasurementSource changed. Rows already in \
source_detail are in the old shape and will not decode — this is a \
stored-data break, not a serialisation detail"
);
let (kind, detail) = encode_source(&MeasurementSource::VirtualMeter {
rule: metering::aggregation_rule::VirtualMeterKind::PvSelfConsumption,
source_ids: vec!["12345678905".to_string()],
})
.unwrap();
assert_eq!(kind, "VIRTUAL_METER");
assert_eq!(
detail,
r#"{"VIRTUAL_METER":{"rule":"PV_SELF_CONSUMPTION","source_ids":["12345678905"]}}"#,
"a nested vocabulary in source_detail changed. Rows written for \
virtual-meter series are in the old shape and no longer decode — and \
source_kind still reads VIRTUAL_METER, so nothing else reports it"
);
let entry = ProvenanceEntry {
occurred_at: datetime!(2026-03-01 00:00 UTC),
event_type: metering::measurement_series::ProvenanceEventType::Ingested,
actor: "MSCONS".to_string(),
note: None,
};
assert_eq!(
encode_provenance(std::slice::from_ref(&entry)).unwrap(),
r#"[{"occurred_at":"2026-03-01T00:00:00Z","event_type":"INGESTED","actor":"MSCONS","note":null}]"#,
"the stored shape of a provenance entry changed. Rows already in the \
provenance column are in the old shape — this is a stored-data \
break, and an audit trail is the one column that must stay readable. \
An `occurred_at` that is not RFC 3339 means `metering::wire` changed"
);
assert_eq!(
decode_provenance(&encode_provenance(std::slice::from_ref(&entry)).unwrap()).unwrap(),
vec![entry],
);
}
#[test]
fn a_provenance_trail_survives_every_event_type_and_a_note() {
use metering::measurement_series::ProvenanceEventType;
let trail: Vec<ProvenanceEntry> = ProvenanceEventType::ALL
.iter()
.enumerate()
.map(|(i, event_type)| ProvenanceEntry {
occurred_at: datetime!(2026-03-01 00:00 UTC) + time::Duration::seconds(i as i64),
event_type: *event_type,
actor: format!("actor-{i}"),
note: (i % 2 == 0).then(|| format!("note {i}")),
})
.collect();
assert_eq!(
decode_provenance(&encode_provenance(&trail).unwrap()).unwrap(),
trail,
);
assert_eq!(encode_provenance(&[]).unwrap(), "[]");
assert_eq!(decode_provenance("[]").unwrap(), Vec::new());
}
#[test]
fn a_malformed_provenance_column_fails_rather_than_decoding_to_nothing() {
for bad in [
r#"{"occurred_at":"2026-03-01T00:00:00Z"}"#,
r#"[{"event_type":"INGESTED","actor":"a","note":null}]"#,
r#"[{"occurred_at":"the first of March","event_type":"INGESTED","actor":"a","note":null}]"#,
r#"[{"occurred_at":"2026-03-01T00:00:00Z","event_type":"ingested","actor":"a","note":null}]"#,
r#"[{"occurred_at":"2026-03-01T00:00:00Z","event_type":"INGESTED","actor":7,"note":null}]"#,
] {
let err = decode_provenance(bad).expect_err("{bad}");
assert!(
err.to_string().contains(col::PROVENANCE) || err.to_string().contains("json"),
"{bad}: {err}"
);
}
}
#[test]
fn every_source_variant_stores_the_tag_its_payload_carries() {
use metering::substitute::{SubstituteMethod, SubstitutionReason};
let sources = [
source(),
MeasurementSource::SmgwDirectPush {
device_id: "d".into(),
session_id: "s".into(),
},
MeasurementSource::ManualEntry {
operator_id: "op".into(),
reason: "dispute".into(),
},
MeasurementSource::AutoSubstitute {
method: SubstituteMethod::ZeroFill,
reason: SubstitutionReason::GatewayCommFailure,
},
];
for source in sources {
let (kind, detail) = encode_source(&source).unwrap();
let payload: serde_json::Value = serde_json::from_str(&detail).unwrap();
assert_eq!(
payload.as_object().unwrap().keys().next().unwrap(),
&kind,
"{source:?}"
);
assert_eq!(decode_source(&kind, Some(&detail)).unwrap(), source);
}
}
#[test]
fn a_non_canonical_code_is_refused_rather_than_normalised() {
use metering::interval::Sparte;
assert_eq!(
decode_code::<Sparte>(col::SPARTE, "WAERME").unwrap(),
Sparte::Waerme
);
for accepted_but_not_written in ["WÄRME", "waerme", " WAERME ", "Waerme"] {
let err = decode_code::<Sparte>(col::SPARTE, accepted_but_not_written)
.expect_err(accepted_but_not_written)
.to_string();
assert!(err.contains("WAERME"), "{err}");
assert!(err.contains("canonical"), "{err}");
}
assert!(decode_code::<MeasurementUnit>(col::UNIT, "KWH").is_ok());
assert!(decode_code::<MeasurementUnit>(col::UNIT, "kwh").is_err());
assert!(decode_code::<QualityFlag>(col::QUALITY, "MEASURED").is_ok());
assert!(decode_code::<QualityFlag>(col::QUALITY, "measured").is_err());
assert!(decode_code::<Sparte>(col::SPARTE, "FERNKAELTE").is_err());
}
#[test]
fn every_code_this_crate_writes_decodes_back() {
use metering::interval::Sparte;
for code in Sparte::CODES {
assert!(decode_code::<Sparte>(col::SPARTE, code).is_ok(), "{code}");
}
for code in MeasurementUnit::CODES {
assert!(
decode_code::<MeasurementUnit>(col::UNIT, code).is_ok(),
"{code}"
);
}
for code in QualityFlag::CODES {
assert!(
decode_code::<QualityFlag>(col::QUALITY, code).is_ok(),
"{code}"
);
}
}
#[test]
fn a_resolution_is_stored_under_one_iso8601_spelling() {
use metering::resolution::IntervalResolution;
assert_eq!(
decode_resolution("PT15M").unwrap(),
IntervalResolution::QuarterHour
);
let err = decode_resolution("PT900S").unwrap_err().to_string();
assert!(err.contains("PT15M"), "{err}");
for r in [
IntervalResolution::QuarterHour,
IntervalResolution::Hour,
IntervalResolution::Day,
IntervalResolution::Month,
IntervalResolution::from_seconds(60).unwrap(),
] {
assert_eq!(decode_resolution(&r.to_iso8601()).unwrap(), r, "{r:?}");
}
}
#[test]
fn decoding_rejects_discriminant_payload_mismatch() {
assert!(
decode_source(
"MANUAL_ENTRY",
Some(&serde_json::to_string(&source()).unwrap())
)
.is_err()
);
assert!(decode_source("mscons", Some(&serde_json::to_string(&source()).unwrap())).is_err());
}
#[test]
fn multiple_series_group_back_correctly() {
let base = datetime!(2026-03-01 00:00 UTC);
let mut a = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
a.series.malo_id = malo("11111111115");
let mut b = series(vec![
quarter(base, "2.0", QualityFlag::Measured),
quarter(
base + time::Duration::minutes(15),
"3.0",
QualityFlag::Measured,
),
]);
b.series.malo_id = malo("22222222220");
let batch = to_record_batch(&[a, b]).unwrap();
assert_eq!(batch.num_rows(), 3);
let out = from_record_batch(&batch).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].series.malo_id, malo("11111111115"));
assert_eq!(out[0].series.intervals.len(), 1);
assert_eq!(out[1].series.malo_id, malo("22222222220"));
assert_eq!(out[1].series.intervals.len(), 2);
}
#[test]
fn corrections_stay_separate_rows_rather_than_being_merged() {
let base = datetime!(2026-03-01 00:00 UTC);
let mut v1 = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
v1.version = ScopedVersion::new(scope_for(base), Version::new(20_260_701_000_001).unwrap());
let mut v2 = series(vec![quarter(base, "9.0", QualityFlag::Corrected)]);
v2.version = ScopedVersion::new(scope_for(base), Version::new(20_260_715_000_002).unwrap());
let batch = to_record_batch(&[v1, v2]).unwrap();
assert_eq!(
batch.num_rows(),
2,
"correction must not overwrite the original"
);
let out = from_record_batch(&batch).unwrap();
assert_eq!(out.len(), 2);
assert!(out[1].version.supersedes(&out[0].version).unwrap());
}
#[test]
fn round_trip_preserves_provenance() {
use metering::measurement_series::ProvenanceEventType;
let base = datetime!(2026-03-01 00:00 UTC);
let mut input = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
input
.series
.record_event(ProvenanceEventType::Ingested, "test-actor", INGESTED_AT);
let expected = input.series.provenance.len();
assert!(expected > 0);
let batch = to_record_batch(std::slice::from_ref(&input)).unwrap();
let out = from_record_batch(&batch).unwrap();
assert_eq!(out[0].series.provenance.len(), expected);
let last = out[0].series.provenance.last().unwrap();
assert_eq!(last.actor, "test-actor");
}
#[test]
fn worst_quality_is_derived_not_stored() {
let base = datetime!(2026-03-01 00:00 UTC);
let input = series(vec![
quarter(base, "1.0", QualityFlag::Measured),
quarter(
base + time::Duration::minutes(15),
"1.0",
QualityFlag::Faulty,
),
]);
let batch = to_record_batch(std::slice::from_ref(&input)).unwrap();
assert!(
batch.column_by_name("worst_quality").is_none(),
"derived field must not be a column"
);
let out = from_record_batch(&batch).unwrap();
assert_eq!(out[0].series.worst_quality(), input.series.worst_quality());
}
#[test]
fn obis_canonicalisation_is_idempotent() {
let once = canonical_obis("1-0:1.8.0").unwrap();
assert_eq!(canonical_obis(&once).unwrap(), once);
}
#[test]
fn obis_short_and_canonical_spellings_converge() {
assert_eq!(
canonical_obis("1-0:1.8.0").unwrap(),
canonical_obis("1-0:1.8.0*255").unwrap()
);
}
#[test]
fn obis_canonicalisation_rejects_nonsense() {
assert!(canonical_obis("not-an-obis-code").is_err());
assert!(canonical_obis("").is_err());
}
#[test]
fn the_encoder_always_writes_the_canonical_form() {
let base = datetime!(2026-03-01 00:00 UTC);
let input = series(vec![quarter(base, "1.0", QualityFlag::Measured)]);
let batch = to_record_batch(&[input]).unwrap();
let stored = batch
.column_by_name(col::OBIS_CODE)
.unwrap()
.as_any()
.downcast_ref::<StringArray>()
.unwrap()
.value(0);
assert_eq!(stored, canonical_obis("1-0:1.8.0").unwrap());
}
fn stored_days(batch: &RecordBatch) -> Vec<time::Date> {
let column = batch
.column_by_name(col::BALANCING_DAY)
.unwrap()
.as_any()
.downcast_ref::<crate::arrow::array::Date32Array>()
.expect("date32 column");
(0..column.len())
.map(|i| schema::date_of(column.value(i)).expect("a stored day is in range"))
.collect()
}
#[test]
fn the_stored_balancing_day_is_the_calendars_answer_for_every_row() {
let mut at = datetime!(2026-10-24 00:00 UTC);
let mut intervals = Vec::new();
while at < datetime!(2026-10-26 00:00 UTC) {
intervals.push(quarter(at, "1.0", QualityFlag::Measured));
at += time::Duration::minutes(15);
}
for sparte in [Sparte::Strom, Sparte::Gas, Sparte::Waerme, Sparte::Wasser] {
let mut s = series(intervals.clone());
s.sparte = sparte;
s.unit = sparte.billing_unit();
s.version = ScopedVersion::new(
scope_for(intervals[0].from),
Version::new(20_261_024_000_001).unwrap(),
);
let batch = to_record_batch(&[s]).unwrap();
let got = stored_days(&batch);
assert_eq!(got.len(), intervals.len());
for (interval, day) in intervals.iter().zip(got) {
assert_eq!(
day,
crate::planner::balancing_day(interval.from, sparte),
"{sparte} at {}",
interval.from
);
}
}
}
#[test]
fn a_gas_series_crossing_0600_local_stores_two_different_days() {
let before = datetime!(2026-07-15 03:45 UTC); let after = datetime!(2026-07-15 04:00 UTC);
let mut s = series(vec![
quarter(before, "1.0", QualityFlag::Measured),
quarter(after, "2.0", QualityFlag::Measured),
]);
s.sparte = Sparte::Gas;
s.unit = Sparte::Gas.billing_unit();
let batch = to_record_batch(&[s]).unwrap();
assert_eq!(
stored_days(&batch),
vec![
time::macros::date!(2026 - 07 - 14),
time::macros::date!(2026 - 07 - 15)
],
"the Gastag turns over at 06:00 local, mid-series"
);
assert_eq!(from_record_batch(&batch).unwrap().len(), 1);
}
#[test]
fn empty_input_produces_an_empty_batch_with_the_right_schema() {
let batch = to_record_batch(&[]).unwrap();
assert_eq!(batch.num_rows(), 0);
assert_eq!(batch.schema(), schema::storage_schema(&[]));
assert!(from_record_batch(&batch).unwrap().is_empty());
}
}