use std::any::Any;
use std::sync::Arc;
use datafusion::common::{DataFusionError, Result as DfResult, ScalarValue};
use datafusion::logical_expr::{
ColumnarValue, ScalarFunctionArgs, ScalarUDF, ScalarUDFImpl, Signature, TypeSignature,
Volatility,
};
use metering::IntervalResolution;
use metering::calendar;
use metering::ids::{Eic, EicType, Regelzone};
use metering::interval::{Direction, Sparte};
use metering::obis::ObisCode;
use time::{Date, OffsetDateTime};
use crate::arrow::array::{
Array, Date32Array, StringArray, TimestampMicrosecondArray, UInt32Array,
};
use crate::arrow::datatypes::{DataType, TimeUnit};
use crate::planner::calendar as balancing;
fn to_date32(date: Date) -> i32 {
crate::encode::schema::date32(date)
}
fn from_micros(micros: i64) -> DfResult<OffsetDateTime> {
crate::encode::schema::instant(micros)
.map_err(|e| DataFusionError::Execution(format!("timestamp out of range: {e}")))
}
fn timestamp_signature() -> Signature {
timestamp_signature_with(&[])
}
fn timestamp_signature_with(trailing: &[DataType]) -> Signature {
Signature::one_of(
[
TimeUnit::Second,
TimeUnit::Millisecond,
TimeUnit::Microsecond,
TimeUnit::Nanosecond,
]
.into_iter()
.flat_map(|unit| {
[Some("UTC".into()), None].map(|tz| {
let mut args = vec![DataType::Timestamp(unit, tz)];
args.extend_from_slice(trailing);
TypeSignature::Exact(args)
})
})
.collect(),
Volatility::Immutable,
)
}
fn as_strings(args: &ScalarFunctionArgs, index: usize) -> DfResult<StringArray> {
let array = args.args[index].clone().into_array(args.number_rows)?;
Ok(crate::arrow::array::AsArray::as_string_opt::<i32>(&array)
.ok_or_else(|| {
DataFusionError::Execution(format!("argument {} must be a string", index + 1))
})?
.clone())
}
fn parse_sparte(s: &str) -> DfResult<Sparte> {
s.parse().map_err(|e| {
DataFusionError::Execution(format!(
"bad sparte {s:?}: {e} — expected one of {:?}",
Sparte::CODES
))
})
}
fn as_micros(args: &ScalarFunctionArgs) -> DfResult<TimestampMicrosecondArray> {
let array = args.args[0].clone().into_array(args.number_rows)?;
let cast = crate::arrow::compute::cast(
&array,
&DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
)?;
Ok(cast
.as_any()
.downcast_ref::<TimestampMicrosecondArray>()
.ok_or_else(|| DataFusionError::Execution("expected a timestamp argument".into()))?
.clone())
}
fn as_obis(args: &ScalarFunctionArgs) -> DfResult<(StringArray, Vec<Option<ObisCode>>)> {
let raw = as_strings(args, 0)?;
let mut out = Vec::with_capacity(raw.len());
for i in 0..raw.len() {
if raw.is_null(i) {
out.push(None);
continue;
}
let text = raw.value(i);
out.push(Some(text.parse::<ObisCode>().map_err(|e| {
DataFusionError::Execution(format!("{text:?} is not an OBIS code: {e}"))
})?));
}
Ok((raw, out))
}
struct ObisPredicate {
name: &'static str,
test: fn(&ObisCode) -> bool,
signature: Signature,
}
impl std::fmt::Debug for ObisPredicate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObisPredicate")
.field("name", &self.name)
.finish()
}
}
impl PartialEq for ObisPredicate {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for ObisPredicate {}
impl std::hash::Hash for ObisPredicate {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
impl ObisPredicate {
fn new(name: &'static str, test: fn(&ObisCode) -> bool) -> Self {
Self {
name,
test,
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for ObisPredicate {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
self.name
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Boolean)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let (_, codes) = as_obis(&args)?;
let out: crate::arrow::array::BooleanArray =
codes.iter().map(|c| c.map(|c| (self.test)(&c))).collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ObisTariffRegister {
signature: Signature,
}
impl Default for ObisTariffRegister {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for ObisTariffRegister {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"obis_tariff_register"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::UInt8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let (_, codes) = as_obis(&args)?;
let out: crate::arrow::array::UInt8Array = codes
.iter()
.map(|c| c.and_then(|c| c.tariff_register()))
.collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ObisNormalise {
signature: Signature,
}
impl Default for ObisNormalise {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for ObisNormalise {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"obis_normalise"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Utf8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let (_, codes) = as_obis(&args)?;
let out: StringArray = codes.iter().map(|c| c.map(|c| c.to_string())).collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ObisDirection {
signature: Signature,
}
impl Default for ObisDirection {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for ObisDirection {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"obis_direction"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Utf8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let (_, codes) = as_obis(&args)?;
let out: StringArray = codes
.iter()
.map(|c| c.and_then(ObisCode::direction).map(Direction::as_str))
.collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
fn as_eic(args: &ScalarFunctionArgs) -> DfResult<Vec<Option<Eic>>> {
let raw = as_strings(args, 0)?;
Ok((0..raw.len())
.map(|i| match raw.is_null(i) {
true => None,
false => raw.value(i).parse::<Eic>().ok(),
})
.collect())
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct EicRegelzone {
signature: Signature,
}
impl Default for EicRegelzone {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for EicRegelzone {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"eic_regelzone"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Utf8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let out: StringArray = as_eic(&args)?
.iter()
.map(|e| e.and_then(|e| e.regelzone()).map(Regelzone::as_str))
.collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct EicNormalise {
signature: Signature,
}
impl Default for EicNormalise {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for EicNormalise {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"eic_normalise"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Utf8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let out: StringArray = as_eic(&args)?
.iter()
.map(|e| e.map(|e| e.as_str().to_string()))
.collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct EicObjectType {
signature: Signature,
}
impl Default for EicObjectType {
fn default() -> Self {
Self {
signature: Signature::exact(vec![DataType::Utf8], Volatility::Immutable),
}
}
}
impl ScalarUDFImpl for EicObjectType {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"eic_object_type"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Utf8)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let out: StringArray = as_eic(&args)?
.iter()
.map(|e| e.and_then(|e| e.object_type()).map(EicType::as_str))
.collect();
Ok(ColumnarValue::Array(Arc::new(out)))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct LocalDay {
signature: Signature,
}
impl Default for LocalDay {
fn default() -> Self {
Self {
signature: timestamp_signature(),
}
}
}
impl ScalarUDFImpl for LocalDay {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_local_day"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Date32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let input = as_micros(&args)?;
let mut out = Date32Array::builder(input.len());
for i in 0..input.len() {
if input.is_null(i) {
out.append_null();
} else {
out.append_value(to_date32(calendar::local_day(from_micros(input.value(i))?)));
}
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct GasDay {
signature: Signature,
}
impl Default for GasDay {
fn default() -> Self {
Self {
signature: timestamp_signature(),
}
}
}
impl ScalarUDFImpl for GasDay {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_gas_day"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Date32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let input = as_micros(&args)?;
let mut out = Date32Array::builder(input.len());
for i in 0..input.len() {
if input.is_null(i) {
out.append_null();
} else {
out.append_value(to_date32(calendar::local_gas_day(from_micros(
input.value(i),
)?)));
}
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct BalancingDay {
signature: Signature,
}
impl Default for BalancingDay {
fn default() -> Self {
Self {
signature: timestamp_signature_with(&[DataType::Utf8]),
}
}
}
impl ScalarUDFImpl for BalancingDay {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_balancing_day"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Date32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let input = as_micros(&args)?;
let sparte = as_strings(&args, 1)?;
let mut out = Date32Array::builder(input.len());
for i in 0..input.len() {
if input.is_null(i) || sparte.is_null(i) {
out.append_null();
continue;
}
out.append_value(to_date32(balancing::balancing_day(
from_micros(input.value(i))?,
parse_sparte(sparte.value(i))?,
)));
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct LocalMonth {
signature: Signature,
}
impl Default for LocalMonth {
fn default() -> Self {
Self {
signature: timestamp_signature(),
}
}
}
impl ScalarUDFImpl for LocalMonth {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_local_month"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Date32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let input = as_micros(&args)?;
let mut out = Date32Array::builder(input.len());
for i in 0..input.len() {
if input.is_null(i) {
out.append_null();
} else {
out.append_value(to_date32(calendar::local_month(from_micros(
input.value(i),
)?)));
}
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct BalancingMonth {
signature: Signature,
}
impl Default for BalancingMonth {
fn default() -> Self {
Self {
signature: timestamp_signature_with(&[DataType::Utf8]),
}
}
}
impl ScalarUDFImpl for BalancingMonth {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_balancing_month"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::Date32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let input = as_micros(&args)?;
let sparte = as_strings(&args, 1)?;
let mut out = Date32Array::builder(input.len());
for i in 0..input.len() {
if input.is_null(i) || sparte.is_null(i) {
out.append_null();
continue;
}
out.append_value(to_date32(balancing::balancing_month(
from_micros(input.value(i))?,
parse_sparte(sparte.value(i))?,
)));
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ExpectedIntervals {
signature: Signature,
}
impl Default for ExpectedIntervals {
fn default() -> Self {
Self {
signature: Signature::one_of(
vec![
TypeSignature::Exact(vec![DataType::Date32, DataType::Utf8]),
TypeSignature::Exact(vec![DataType::Date32, DataType::Utf8, DataType::Utf8]),
],
Volatility::Immutable,
),
}
}
}
impl ScalarUDFImpl for ExpectedIntervals {
fn as_any(&self) -> &dyn Any {
self
}
fn name(&self) -> &str {
"meter_expected_intervals"
}
fn signature(&self) -> &Signature {
&self.signature
}
fn return_type(&self, _: &[DataType]) -> DfResult<DataType> {
Ok(DataType::UInt32)
}
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DfResult<ColumnarValue> {
let days = args.args[0].clone().into_array(args.number_rows)?;
let days = days
.as_any()
.downcast_ref::<Date32Array>()
.ok_or_else(|| DataFusionError::Execution("first argument must be a date".into()))?;
let literal = match &args.args[1] {
ColumnarValue::Scalar(ScalarValue::Utf8(Some(s))) => Some(Some(parse_resolution(s)?)),
ColumnarValue::Scalar(ScalarValue::Utf8(None)) => Some(None),
_ => None,
};
let per_row = match literal {
Some(_) => None,
None => {
let array = args.args[1].clone().into_array(args.number_rows)?;
let strings = crate::arrow::array::AsArray::as_string_opt::<i32>(&array)
.ok_or_else(|| {
DataFusionError::Execution("second argument must be a string".into())
})?
.clone();
Some(strings)
}
};
let sparte = match args.args.len() {
3 => Some(as_strings(&args, 2)?),
_ => None,
};
let mut out = UInt32Array::builder(days.len());
for i in 0..days.len() {
let resolution = match (&literal, &per_row) {
(Some(value), _) => *value,
(None, Some(strings)) if !strings.is_null(i) => {
Some(parse_resolution(strings.value(i))?)
}
_ => None,
};
let commodity = match &sparte {
None => Some(Sparte::Strom),
Some(codes) if codes.is_null(i) => None,
Some(codes) => Some(parse_sparte(codes.value(i))?),
};
match (days.is_null(i), resolution, commodity) {
(false, Some(res), Some(sparte)) => {
let date = crate::encode::schema::date_of(days.value(i))
.map_err(|e| DataFusionError::Execution(e.to_string()))?;
match balancing::expected_intervals_in_balancing_day(date, res, sparte) {
Some(n) => out.append_value(n),
None => out.append_null(),
}
}
_ => out.append_null(),
}
}
Ok(ColumnarValue::Array(Arc::new(out.finish())))
}
}
fn parse_resolution(s: &str) -> DfResult<IntervalResolution> {
s.parse()
.map_err(|e| DataFusionError::Execution(format!("bad resolution {s:?}: {e}")))
}
pub fn all() -> Vec<ScalarUDF> {
let mut udfs = vec![
ScalarUDF::from(LocalDay::default()),
ScalarUDF::from(GasDay::default()),
ScalarUDF::from(BalancingDay::default()),
ScalarUDF::from(LocalMonth::default()),
ScalarUDF::from(BalancingMonth::default()),
ScalarUDF::from(ExpectedIntervals::default()),
ScalarUDF::from(ObisTariffRegister::default()),
ScalarUDF::from(ObisNormalise::default()),
ScalarUDF::from(ObisDirection::default()),
ScalarUDF::from(EicRegelzone::default()),
ScalarUDF::from(EicObjectType::default()),
ScalarUDF::from(EicNormalise::default()),
];
for (name, test) in [
(
"obis_is_import",
(|c: &ObisCode| c.is_import()) as fn(&ObisCode) -> bool,
),
("obis_is_export", |c: &ObisCode| c.is_export()),
("obis_is_reactive", ObisCode::is_reactive),
("obis_is_lastgang", ObisCode::is_lastgang),
("obis_is_zaehlerstand", ObisCode::is_zaehlerstand),
("obis_is_vorschub", ObisCode::is_vorschub),
("obis_is_maximum", ObisCode::is_maximum),
("obis_is_fehlerregister", ObisCode::is_fehlerregister),
("obis_is_total_register", ObisCode::is_total_register),
] {
udfs.push(ScalarUDF::from(ObisPredicate::new(name, test)));
}
udfs
}
#[cfg(test)]
mod tests {
use super::*;
use datafusion::prelude::SessionContext;
use time::macros::{date, datetime};
fn ctx() -> SessionContext {
let ctx = SessionContext::new();
for udf in all() {
ctx.register_udf(udf);
}
ctx
}
async fn one_date(sql: &str) -> Option<Date> {
let batches = ctx().sql(sql).await.unwrap().collect().await.unwrap();
let array = batches[0]
.column(0)
.as_any()
.downcast_ref::<Date32Array>()
.expect("date32 result");
(!array.is_null(0)).then(|| crate::encode::schema::date_of(array.value(0)).unwrap())
}
async fn one_u32(sql: &str) -> Option<u32> {
let batches = ctx().sql(sql).await.unwrap().collect().await.unwrap();
let array = batches[0]
.column(0)
.as_any()
.downcast_ref::<UInt32Array>()
.expect("uint32 result");
(!array.is_null(0)).then(|| array.value(0))
}
#[tokio::test]
async fn local_day_uses_the_berlin_boundary_not_the_utc_one() {
let got = one_date("SELECT meter_local_day(TIMESTAMP '2026-07-20T23:00:00Z')").await;
assert_eq!(got, Some(date!(2026 - 07 - 21)));
}
#[tokio::test]
async fn local_day_matches_metering_directly() {
for instant in [
datetime!(2026-07-20 12:00 UTC),
datetime!(2026-01-20 23:30 UTC),
datetime!(2026-03-29 01:30 UTC),
datetime!(2026-10-25 01:30 UTC),
] {
let sql = format!(
"SELECT meter_local_day(TIMESTAMP '{}')",
instant
.format(&time::format_description::well_known::Rfc3339)
.unwrap()
);
assert_eq!(
one_date(&sql).await,
Some(calendar::local_day(instant)),
"wrapper disagreed with metering for {instant}"
);
}
}
async fn one_string(sql: &str) -> Option<String> {
use crate::arrow::array::AsArray;
let batches = ctx().sql(sql).await.unwrap().collect().await.unwrap();
let column = batches[0].column(0).as_string::<i32>();
(!column.is_null(0)).then(|| column.value(0).to_string())
}
async fn one_bool(sql: &str) -> Option<bool> {
use crate::arrow::array::AsArray;
let batches = ctx().sql(sql).await.unwrap().collect().await.unwrap();
let column = batches[0].column(0).as_boolean();
(!column.is_null(0)).then(|| column.value(0))
}
#[tokio::test]
async fn obis_predicates_answer_what_metering_answers() {
for (sql, want) in [
("obis_is_import('1-0:1.8.0')", true),
("obis_is_import('1-0:2.8.0')", false),
("obis_is_export('1-0:2.29.0')", true),
("obis_is_import('1-0:1.29.0')", true),
("obis_is_lastgang('1-0:1.29.0')", true),
("obis_is_zaehlerstand('1-0:1.8.0')", true),
("obis_is_vorschub('1-0:1.9.0')", true),
("obis_is_maximum('1-0:1.6.0')", true),
("obis_is_maximum('1-0:1.29.0')", false),
("obis_is_reactive('1-0:5.8.0')", true),
("obis_is_reactive('1-0:1.8.0')", false),
("obis_is_fehlerregister('1-0:1.8.63')", true),
("obis_is_total_register('1-0:1.8.0')", true),
("obis_is_total_register('1-0:1.8.1')", false),
] {
assert_eq!(
one_bool(&format!("SELECT {sql}")).await,
Some(want),
"{sql}"
);
}
}
#[tokio::test]
async fn direction_is_medium_aware_without_being_told_the_commodity() {
assert_eq!(
one_bool("SELECT obis_is_import('7-1:99.33.0')").await,
Some(false)
);
assert_eq!(
one_bool("SELECT obis_is_export('7-1:99.33.0')").await,
Some(false)
);
}
#[tokio::test]
async fn a_fault_counter_is_not_tariff_sixty_three() {
use crate::arrow::array::AsArray;
let batches = ctx()
.sql(
"SELECT obis_tariff_register('1-0:1.8.1') AS ht, \
obis_tariff_register('1-0:1.8.0') AS total, \
obis_tariff_register('1-0:1.8.63') AS fault",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let b = &batches[0];
let value = |name: &str| {
let c = b
.column_by_name(name)
.unwrap()
.as_primitive::<crate::arrow::datatypes::UInt8Type>();
(!c.is_null(0)).then(|| c.value(0))
};
assert_eq!(value("ht"), Some(1));
assert_eq!(value("total"), None, "the total register is not a tariff");
assert_eq!(
value("fault"),
None,
"E = 63 is a fault counter, and reporting it as tariff 63 invites \
billing it as consumption"
);
}
#[tokio::test]
async fn obis_normalise_matches_the_stored_spelling() {
use crate::arrow::array::AsArray;
let batches = ctx()
.sql("SELECT obis_normalise('1-0:1.8.0*255') AS c")
.await
.unwrap()
.collect()
.await
.unwrap();
assert_eq!(
batches[0].column(0).as_string::<i32>().value(0),
"1-0:1.8.0"
);
}
#[tokio::test]
async fn a_null_code_is_null_and_a_bad_one_is_an_error() {
assert_eq!(
one_bool("SELECT obis_is_import(CAST(NULL AS VARCHAR))").await,
None
);
assert!(
ctx()
.sql("SELECT obis_is_import('not-an-obis-code')")
.await
.unwrap()
.collect()
.await
.is_err(),
"storage holds canonical codes, so an unparseable one is a statement \
about the row rather than a null"
);
}
#[tokio::test]
async fn local_month_normalises_to_the_first() {
assert_eq!(
one_date("SELECT meter_local_month(TIMESTAMP '2026-07-20T12:00:00Z')").await,
Some(date!(2026 - 07 - 01))
);
assert_eq!(
one_date("SELECT meter_local_month(TIMESTAMP '2026-07-31T23:00:00Z')").await,
Some(date!(2026 - 08 - 01))
);
}
#[tokio::test]
async fn expected_intervals_knows_the_dst_days() {
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-07-20', 'PT15M')").await,
Some(96)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-03-29', 'PT15M')").await,
Some(92),
"spring forward"
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-25', 'PT15M')").await,
Some(100),
"autumn back"
);
}
#[tokio::test]
async fn expected_intervals_handles_other_resolutions() {
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-25', 'PT1H')").await,
Some(25)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-07-20', 'PT30M')").await,
Some(48)
);
}
#[tokio::test]
async fn a_calendar_resolution_has_no_interval_count_within_a_day() {
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-07-20', 'P1M')").await,
None
);
}
#[tokio::test]
async fn a_resolution_column_is_read_per_row() {
let batches = ctx()
.sql(
"SELECT meter_expected_intervals(d, r) AS n FROM (
SELECT DATE '2026-07-20' AS d, 'PT15M' AS r
UNION ALL SELECT DATE '2026-07-20', 'PT1H'
UNION ALL SELECT DATE '2026-07-20', 'PT30M'
) ORDER BY n",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let got: Vec<u32> = batches
.iter()
.flat_map(|b| {
let a = b
.column(0)
.as_any()
.downcast_ref::<UInt32Array>()
.expect("uint32")
.clone();
(0..a.len()).map(move |i| a.value(i)).collect::<Vec<_>>()
})
.collect();
assert_eq!(got, vec![24, 48, 96]);
}
#[tokio::test]
async fn a_null_resolution_in_a_column_yields_null_for_that_row_only() {
let batches = ctx()
.sql(
"SELECT meter_expected_intervals(d, r) AS n FROM (
SELECT DATE '2026-07-20' AS d, CAST(NULL AS VARCHAR) AS r
UNION ALL SELECT DATE '2026-07-20', 'PT15M'
)",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let mut values = Vec::new();
for b in &batches {
let a = b.column(0).as_any().downcast_ref::<UInt32Array>().unwrap();
for i in 0..a.len() {
values.push((!a.is_null(i)).then(|| a.value(i)));
}
}
values.sort();
assert_eq!(values, vec![None, Some(96)]);
}
#[tokio::test]
async fn nulls_propagate() {
assert_eq!(
one_date("SELECT meter_local_day(CAST(NULL AS TIMESTAMP))").await,
None
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(CAST(NULL AS DATE), 'PT15M')").await,
None
);
}
#[tokio::test]
async fn a_bad_resolution_is_an_error_not_a_wrong_number() {
let result = ctx()
.sql("SELECT meter_expected_intervals(DATE '2026-07-20', 'fortnightly')")
.await
.unwrap()
.collect()
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn local_day_groups_a_series_correctly() {
let batches = ctx()
.sql(
"SELECT meter_local_day(t) AS d, COUNT(*) AS n FROM (
SELECT TIMESTAMP '2026-07-20T21:00:00Z' AS t
UNION ALL SELECT TIMESTAMP '2026-07-20T23:00:00Z'
) GROUP BY 1 ORDER BY 1",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(rows, 2, "same UTC day, different Berlin days");
}
#[tokio::test]
async fn gas_day_starts_at_0600_local_not_at_midnight() {
assert_eq!(
one_date("SELECT meter_gas_day(TIMESTAMP '2026-07-15T03:00:00Z')").await,
Some(date!(2026 - 07 - 14))
);
assert_eq!(
one_date("SELECT meter_gas_day(TIMESTAMP '2026-07-15T04:00:00Z')").await,
Some(date!(2026 - 07 - 15))
);
assert_eq!(
one_date("SELECT meter_gas_day(TIMESTAMP '2026-01-15T04:59:00Z')").await,
Some(date!(2026 - 01 - 14))
);
assert_eq!(
one_date("SELECT meter_gas_day(TIMESTAMP '2026-01-15T05:00:00Z')").await,
Some(date!(2026 - 01 - 15))
);
}
#[tokio::test]
async fn gas_day_matches_metering_directly() {
for instant in [
datetime!(2026-07-15 03:00 UTC),
datetime!(2026-01-15 05:00 UTC),
datetime!(2026-03-29 01:30 UTC),
datetime!(2026-10-25 01:30 UTC),
datetime!(2026-10-25 05:30 UTC),
] {
let sql = format!(
"SELECT meter_gas_day(TIMESTAMP '{}')",
instant
.format(&time::format_description::well_known::Rfc3339)
.unwrap()
);
assert_eq!(
one_date(&sql).await,
Some(calendar::local_gas_day(instant)),
"wrapper disagreed with metering for {instant}"
);
}
}
#[tokio::test]
async fn balancing_day_follows_the_commodity() {
let at = "TIMESTAMP '2026-07-14T22:15:00Z'";
assert_eq!(
one_date(&format!("SELECT meter_balancing_day({at}, 'STROM')")).await,
Some(date!(2026 - 07 - 15))
);
assert_eq!(
one_date(&format!("SELECT meter_balancing_day({at}, 'GAS')")).await,
Some(date!(2026 - 07 - 14))
);
for sparte in ["WAERME", "WASSER"] {
assert_eq!(
one_date(&format!("SELECT meter_balancing_day({at}, '{sparte}')")).await,
Some(date!(2026 - 07 - 15)),
"{sparte}"
);
}
}
#[tokio::test]
async fn balancing_day_reads_the_commodity_per_row() {
let batches = ctx()
.sql(
"SELECT sparte, meter_balancing_day(t, sparte) AS d FROM (
SELECT TIMESTAMP '2026-07-14T22:15:00Z' AS t, 'GAS' AS sparte
UNION ALL SELECT TIMESTAMP '2026-07-14T22:15:00Z', 'STROM'
) ORDER BY sparte",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let epoch = Date::from_ordinal_date(1970, 1).unwrap();
let days = batches[0]
.column(1)
.as_any()
.downcast_ref::<Date32Array>()
.unwrap();
assert_eq!(
epoch + time::Duration::days(i64::from(days.value(0))),
date!(2026 - 07 - 14),
"GAS sorts first"
);
assert_eq!(
epoch + time::Duration::days(i64::from(days.value(1))),
date!(2026 - 07 - 15),
"STROM must not inherit the gas day"
);
}
#[tokio::test]
async fn balancing_day_rejects_an_unknown_commodity() {
let err = ctx()
.sql("SELECT meter_balancing_day(TIMESTAMP '2026-07-15T03:00:00Z', 'OEL')")
.await
.unwrap()
.collect()
.await;
assert!(err.is_err(), "an unknown Sparte must not be defaulted");
}
#[tokio::test]
async fn expected_intervals_puts_the_gas_dst_day_on_the_saturday() {
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-24', 'PT15M', 'GAS')").await,
Some(100)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-25', 'PT15M', 'GAS')").await,
Some(96)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-24', 'PT15M', 'STROM')").await,
Some(96)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-25', 'PT15M', 'STROM')").await,
Some(100)
);
}
#[tokio::test]
async fn expected_intervals_without_a_commodity_is_the_calendar_day() {
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-10-25', 'PT15M')").await,
Some(100)
);
assert_eq!(
one_u32("SELECT meter_expected_intervals(DATE '2026-03-29', 'PT15M')").await,
Some(92)
);
}
#[tokio::test]
async fn expected_intervals_is_null_for_an_unknown_commodity_column() {
assert_eq!(
one_u32(
"SELECT meter_expected_intervals(DATE '2026-10-25', 'PT15M', \
CAST(NULL AS VARCHAR))"
)
.await,
None
);
}
#[tokio::test]
async fn balancing_month_follows_the_commodity() {
let at = "TIMESTAMP '2026-03-01T01:00:00Z'";
assert_eq!(
one_date(&format!("SELECT meter_balancing_month({at}, 'STROM')")).await,
Some(date!(2026 - 03 - 01))
);
assert_eq!(
one_date(&format!("SELECT meter_balancing_month({at}, 'GAS')")).await,
Some(date!(2026 - 02 - 01))
);
assert_eq!(
one_date(&format!("SELECT meter_local_month({at})")).await,
Some(date!(2026 - 03 - 01)),
"the calendar month is the electricity answer for every commodity"
);
for sparte in ["WAERME", "WASSER"] {
assert_eq!(
one_date(&format!("SELECT meter_balancing_month({at}, '{sparte}')")).await,
Some(date!(2026 - 03 - 01)),
"{sparte}"
);
}
}
#[tokio::test]
async fn balancing_month_reads_the_commodity_per_row() {
let batches = ctx()
.sql(
"SELECT sparte, meter_balancing_month(t, sparte) AS m FROM (
SELECT TIMESTAMP '2026-03-01T01:00:00Z' AS t, 'GAS' AS sparte
UNION ALL SELECT TIMESTAMP '2026-03-01T01:00:00Z', 'STROM'
) ORDER BY sparte",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let epoch = Date::from_ordinal_date(1970, 1).unwrap();
let months = batches[0]
.column(1)
.as_any()
.downcast_ref::<Date32Array>()
.unwrap();
assert_eq!(
epoch + time::Duration::days(i64::from(months.value(0))),
date!(2026 - 02 - 01),
"GAS sorts first"
);
assert_eq!(
epoch + time::Duration::days(i64::from(months.value(1))),
date!(2026 - 03 - 01),
"STROM must not inherit the gas month"
);
}
#[tokio::test]
async fn balancing_month_refuses_an_unknown_commodity_and_propagates_nulls() {
assert!(
ctx()
.sql("SELECT meter_balancing_month(TIMESTAMP '2026-03-01T01:00:00Z', 'OEL')")
.await
.unwrap()
.collect()
.await
.is_err(),
"an unknown Sparte must not be defaulted"
);
assert_eq!(
one_date("SELECT meter_balancing_month(CAST(NULL AS TIMESTAMP), 'GAS')").await,
None
);
assert_eq!(
one_date(
"SELECT meter_balancing_month(TIMESTAMP '2026-03-01T01:00:00Z', \
CAST(NULL AS VARCHAR))"
)
.await,
None
);
}
#[tokio::test]
async fn obis_direction_separates_undirected_from_export() {
use crate::arrow::array::AsArray;
let batches = ctx()
.sql(
"SELECT obis_direction('1-0:1.8.0') AS bezug, \
obis_direction('1-0:2.29.0') AS einspeisung, \
obis_direction('1-0:3.8.0') AS blind, \
obis_direction(CAST(NULL AS VARCHAR)) AS nothing",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let b = &batches[0];
let value = |name: &str| {
let c = b.column_by_name(name).unwrap().as_string::<i32>();
(!c.is_null(0)).then(|| c.value(0).to_string())
};
assert_eq!(value("bezug").as_deref(), Some("IMPORT"));
assert_eq!(value("einspeisung").as_deref(), Some("EXPORT"));
assert_eq!(value("blind"), None);
assert_eq!(value("nothing"), None);
}
#[tokio::test]
async fn obis_direction_agrees_with_the_predicates_it_is_the_primitive_for() {
for code in [
"1-0:1.8.0",
"1-0:2.8.0",
"1-0:1.29.0",
"1-0:2.29.0",
"1-0:3.8.0",
"7-1:99.33.0",
] {
let direction = {
use crate::arrow::array::AsArray;
let batches = ctx()
.sql(&format!("SELECT obis_direction('{code}')"))
.await
.unwrap()
.collect()
.await
.unwrap();
let c = batches[0].column(0).as_string::<i32>();
(!c.is_null(0)).then(|| c.value(0).to_string())
};
assert_eq!(
direction.as_deref() == Some("IMPORT"),
one_bool(&format!("SELECT obis_is_import('{code}')"))
.await
.unwrap(),
"{code}"
);
assert_eq!(
direction.as_deref() == Some("EXPORT"),
one_bool(&format!("SELECT obis_is_export('{code}')"))
.await
.unwrap(),
"{code}"
);
}
}
#[tokio::test]
async fn eic_regelzone_reads_position_four_of_a_bilanzierungsgebiet() {
for (code, want) in [
("11YN000000000016", Some("TENNET")),
("11YV00000000001D", Some("FIFTY_HERTZ")),
("11XBK0000000001A", None),
("10X168Y4E6H0041Z", None),
] {
assert_eq!(
one_string(&format!("SELECT eic_regelzone('{code}')"))
.await
.as_deref(),
want,
"{code}"
);
}
}
#[tokio::test]
async fn a_column_that_is_not_an_eic_is_null_rather_than_a_failed_report() {
use crate::arrow::array::AsArray;
let batches = ctx()
.sql(
"SELECT eic_regelzone(c) AS z FROM (
SELECT '11YN000000000016' AS c
UNION ALL SELECT 'not an eic'
UNION ALL SELECT CAST(NULL AS VARCHAR)
) ORDER BY c",
)
.await
.unwrap()
.collect()
.await
.unwrap();
let mut seen = Vec::new();
for b in &batches {
let c = b.column(0).as_string::<i32>();
for i in 0..c.len() {
seen.push((!c.is_null(i)).then(|| c.value(i).to_string()));
}
}
seen.sort();
assert_eq!(seen, vec![None, None, Some("TENNET".to_string())]);
}
#[tokio::test]
async fn eic_object_type_reads_position_three_and_agrees_with_metering() {
for code in [
"11XBK0000000001A",
"11YN000000000016",
"10X168Y4E6H0041Z",
"10X---ENTSOE---L",
] {
let want = code
.parse::<Eic>()
.expect("a valid EIC")
.object_type()
.map(EicType::as_str);
assert_eq!(
one_string(&format!("SELECT eic_object_type('{code}')"))
.await
.as_deref(),
want,
"{code}"
);
}
assert_eq!(
one_string("SELECT eic_object_type('11YN000000000016')")
.await
.as_deref(),
Some("Y")
);
assert_eq!(
one_string("SELECT eic_object_type('not an eic')").await,
None
);
assert_eq!(
one_string("SELECT eic_object_type(CAST(NULL AS VARCHAR))").await,
None
);
}
#[tokio::test]
async fn eic_normalise_tells_the_two_kinds_of_missing_object_type_apart() {
const UNLISTED: &str = "11QBK0000000001Y";
assert!(
UNLISTED.parse::<Eic>().is_ok(),
"the fixture must be a valid EIC, or it tests the other branch"
);
assert_eq!(UNLISTED.parse::<Eic>().unwrap().object_type(), None);
for code in [UNLISTED, "not an eic"] {
assert_eq!(
one_string(&format!("SELECT eic_object_type('{code}')")).await,
None,
"{code}"
);
}
assert_eq!(
one_string(&format!("SELECT eic_normalise('{UNLISTED}')"))
.await
.as_deref(),
Some(UNLISTED)
);
assert_eq!(one_string("SELECT eic_normalise('not an eic')").await, None);
assert_eq!(
one_string("SELECT eic_normalise(' 11xbk0000000001a ')")
.await
.as_deref(),
Some("11XBK0000000001A")
);
assert_eq!(
one_string("SELECT eic_normalise(CAST(NULL AS VARCHAR))").await,
None
);
}
#[tokio::test]
async fn a_transposed_eic_reads_as_no_regelzone_rather_than_the_wrong_one() {
assert_eq!(
one_string("SELECT eic_regelzone('11YN000000000017')").await,
None
);
}
#[tokio::test]
async fn gas_day_is_null_for_a_null_instant() {
assert_eq!(
one_date("SELECT meter_gas_day(CAST(NULL AS TIMESTAMP))").await,
None
);
assert_eq!(
one_date("SELECT meter_balancing_day(CAST(NULL AS TIMESTAMP), 'GAS')").await,
None
);
assert_eq!(
one_date(
"SELECT meter_balancing_day(TIMESTAMP '2026-07-15T03:00:00Z', \
CAST(NULL AS VARCHAR))"
)
.await,
None
);
}
}