use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::aggregation_rule::AggregationRule;
use crate::interval::{MeterInterval, QualityFlag};
pub type SourceMap<S = std::collections::hash_map::RandomState> =
HashMap<String, Vec<MeterInterval>, S>;
#[derive(Debug, thiserror::Error, PartialEq)]
#[non_exhaustive]
pub enum VirtualMeterError {
#[error("missing source MaLo: {0}")]
MissingSource(String),
#[error("GGV tenant fractions sum to {sum} — must be in (0, 1]")]
InvalidFractions {
sum: Decimal,
},
}
pub fn compute_virtual_meter<S: BuildHasher>(
rule: &AggregationRule,
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
match rule {
AggregationRule::Sum { source_malo_ids } => compute_sum(source_malo_ids, sources),
AggregationRule::Residual {
total_malo_id,
subtract_malo_ids,
} => compute_residual(total_malo_id, subtract_malo_ids, sources),
AggregationRule::PvSelfConsumption {
grid_malo_id,
generation_malo_id,
} => compute_net_grid(grid_malo_id, generation_malo_id, sources),
AggregationRule::GgvConstantAllocation {
plant_melo_id,
tenant_melo_id,
fraction,
} => compute_ggv_constant(plant_melo_id, tenant_melo_id, *fraction, sources),
AggregationRule::GgvProportionalAllocation {
plant_melo_id,
tenant_melo_id,
all_tenant_melo_ids,
} => compute_ggv_proportional(plant_melo_id, tenant_melo_id, all_tenant_melo_ids, sources),
}
}
fn compute_sum<S: BuildHasher>(
malo_ids: &[String],
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
for id in malo_ids {
if !sources.contains_key(id.as_str()) {
return Err(VirtualMeterError::MissingSource(id.clone()));
}
}
let index = SourceIndex::build(sources);
let aligned = aligned_timestamps(malo_ids.iter().map(String::as_str), sources);
let mut result = Vec::with_capacity(aligned.len());
for ts in aligned {
let Some(ivs) = malo_ids
.iter()
.map(|id| index.get(id, ts))
.collect::<Option<Vec<_>>>()
else {
continue;
};
let mut sum = Decimal::ZERO;
let mut quality = QualityFlag::Measured;
let mut end: Option<OffsetDateTime> = None;
for iv in ivs {
sum += iv.value;
quality = quality.worse_of(iv.quality);
end = Some(iv.to);
}
if let Some(to) = end {
result.push(MeterInterval {
from: ts,
to,
value: sum,
quality,
obis_code: None,
});
}
}
Ok(result)
}
fn compute_residual<S: BuildHasher>(
total_id: &str,
subtract_ids: &[String],
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
if !sources.contains_key(total_id) {
return Err(VirtualMeterError::MissingSource(total_id.to_owned()));
}
for id in subtract_ids {
if !sources.contains_key(id.as_str()) {
return Err(VirtualMeterError::MissingSource(id.clone()));
}
}
let index = SourceIndex::build(sources);
let all_ids = std::iter::once(total_id).chain(subtract_ids.iter().map(String::as_str));
let aligned = aligned_timestamps(all_ids, sources);
let mut result = Vec::with_capacity(aligned.len());
for ts in aligned {
let Some(total_iv) = index.get(total_id, ts) else {
continue;
};
let Some(subtract_ivs) = subtract_ids
.iter()
.map(|id| index.get(id, ts))
.collect::<Option<Vec<_>>>()
else {
continue;
};
let mut subtract_sum = Decimal::ZERO;
let mut quality = total_iv.quality;
for iv in subtract_ivs {
subtract_sum += iv.value;
quality = quality.worse_of(iv.quality);
}
result.push(MeterInterval {
from: ts,
to: total_iv.to,
value: total_iv.value - subtract_sum,
quality,
obis_code: None,
});
}
Ok(result)
}
fn compute_net_grid<S: BuildHasher>(
grid_id: &str,
gen_id: &str,
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
if !sources.contains_key(grid_id) {
return Err(VirtualMeterError::MissingSource(grid_id.to_owned()));
}
if !sources.contains_key(gen_id) {
return Err(VirtualMeterError::MissingSource(gen_id.to_owned()));
}
let index = SourceIndex::build(sources);
let aligned = aligned_timestamps([grid_id, gen_id].iter().copied(), sources);
let mut result = Vec::with_capacity(aligned.len());
for ts in aligned {
let (Some(grid_iv), Some(gen_iv)) = (index.get(grid_id, ts), index.get(gen_id, ts)) else {
continue;
};
result.push(MeterInterval {
from: ts,
to: grid_iv.to,
value: grid_iv.value - gen_iv.value,
quality: grid_iv.quality.worse_of(gen_iv.quality),
obis_code: None,
});
}
Ok(result)
}
fn compute_ggv_constant<S: BuildHasher>(
plant_id: &str,
tenant_id: &str,
fraction: Decimal,
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
if !sources.contains_key(plant_id) {
return Err(VirtualMeterError::MissingSource(plant_id.to_owned()));
}
if !sources.contains_key(tenant_id) {
return Err(VirtualMeterError::MissingSource(tenant_id.to_owned()));
}
if fraction <= Decimal::ZERO || fraction > Decimal::ONE {
return Err(VirtualMeterError::InvalidFractions { sum: fraction });
}
let index = SourceIndex::build(sources);
let aligned = aligned_timestamps([plant_id, tenant_id].iter().copied(), sources);
let mut result = Vec::with_capacity(aligned.len());
for ts in aligned {
let (Some(plant_iv), Some(tenant_iv)) = (index.get(plant_id, ts), index.get(tenant_id, ts))
else {
continue;
};
let allocated = fraction * plant_iv.value;
let net_grid_draw = (tenant_iv.value - allocated).max(Decimal::ZERO);
result.push(MeterInterval {
from: ts,
to: tenant_iv.to,
value: net_grid_draw,
quality: plant_iv.quality.worse_of(tenant_iv.quality),
obis_code: None,
});
}
Ok(result)
}
fn compute_ggv_proportional<S: BuildHasher>(
plant_id: &str,
tenant_id: &str,
all_tenant_ids: &[String],
sources: &SourceMap<S>,
) -> Result<Vec<MeterInterval>, VirtualMeterError> {
if !sources.contains_key(plant_id) {
return Err(VirtualMeterError::MissingSource(plant_id.to_owned()));
}
for id in all_tenant_ids {
if !sources.contains_key(id.as_str()) {
return Err(VirtualMeterError::MissingSource(id.clone()));
}
}
if !sources.contains_key(tenant_id) {
return Err(VirtualMeterError::MissingSource(tenant_id.to_owned()));
}
let all_ids: Vec<&str> = std::iter::once(plant_id)
.chain(all_tenant_ids.iter().map(String::as_str))
.collect();
let index = SourceIndex::build(sources);
let aligned = aligned_timestamps(all_ids.iter().copied(), sources);
let mut result = Vec::with_capacity(aligned.len());
for ts in aligned {
let (Some(plant_iv), Some(tenant_iv)) = (index.get(plant_id, ts), index.get(tenant_id, ts))
else {
continue;
};
let Some(total_consumption) = all_tenant_ids
.iter()
.map(|id| index.get(id, ts).map(|iv| iv.value))
.sum::<Option<Decimal>>()
else {
continue;
};
let net_grid_draw = if total_consumption > Decimal::ZERO {
let ratio = tenant_iv.value / total_consumption;
let allocated = ratio * plant_iv.value;
(tenant_iv.value - allocated).max(Decimal::ZERO)
} else {
Decimal::ZERO
};
let quality = all_tenant_ids
.iter()
.filter_map(|id| index.get(id, ts))
.fold(plant_iv.quality, |q, iv| q.worse_of(iv.quality));
result.push(MeterInterval {
from: ts,
to: tenant_iv.to,
value: net_grid_draw,
quality,
obis_code: None,
});
}
Ok(result)
}
fn aligned_timestamps<'a, S: BuildHasher>(
malo_ids: impl Iterator<Item = &'a str>,
sources: &SourceMap<S>,
) -> Vec<OffsetDateTime> {
let ids: Vec<&str> = malo_ids.collect();
let Some((first, rest)) = ids.split_first() else {
return Vec::new();
};
let series = |id: &str| -> HashSet<i64> {
sources
.get(id)
.map(|ivs| ivs.iter().map(|iv| iv.from.unix_timestamp()).collect())
.unwrap_or_default()
};
let intersection = rest.iter().fold(series(first), |acc, id| {
let other = series(id);
acc.intersection(&other).copied().collect()
});
let mut sorted: Vec<i64> = intersection.into_iter().collect();
sorted.sort_unstable();
sorted
.into_iter()
.filter_map(|t| OffsetDateTime::from_unix_timestamp(t).ok())
.collect()
}
struct SourceIndex<'a> {
by_id: HashMap<&'a str, HashMap<i64, &'a MeterInterval>>,
}
impl<'a> SourceIndex<'a> {
fn build<S: BuildHasher>(sources: &'a SourceMap<S>) -> Self {
let by_id = sources
.iter()
.map(|(id, ivs)| {
let index = ivs
.iter()
.map(|iv| (iv.from.unix_timestamp(), iv))
.collect();
(id.as_str(), index)
})
.collect();
Self { by_id }
}
fn get(&self, malo_id: &str, ts: OffsetDateTime) -> Option<&'a MeterInterval> {
self.by_id
.get(malo_id)
.and_then(|index| index.get(&ts.unix_timestamp()))
.copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
fn make_iv(from: OffsetDateTime, kwh: Decimal, quality: QualityFlag) -> MeterInterval {
MeterInterval {
from,
to: from + Duration::minutes(15),
value: kwh,
quality,
obis_code: None,
}
}
fn ts(offset_min: i64) -> OffsetDateTime {
datetime!(2026-01-01 00:00 UTC) + Duration::minutes(offset_min)
}
fn source(id: &str, values: Vec<(i64, Decimal)>) -> (String, Vec<MeterInterval>) {
let ivs = values
.into_iter()
.map(|(min, kwh)| make_iv(ts(min), kwh, QualityFlag::Measured))
.collect();
(id.to_owned(), ivs)
}
#[test]
fn sum_rule_adds_two_series() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let (ka, va) = source("A", vec![(0, dec!(3.0)), (15, dec!(3.0))]);
let (kb, vb) = source("B", vec![(0, dec!(2.0)), (15, dec!(2.0))]);
map.insert(ka, va);
map.insert(kb, vb);
let rule = AggregationRule::Sum {
source_malo_ids: vec!["A".to_owned(), "B".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].value, dec!(5.0));
}
#[test]
fn sum_missing_source_returns_error() {
let map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let rule = AggregationRule::Sum {
source_malo_ids: vec!["MISSING".to_owned()],
};
assert!(matches!(
compute_virtual_meter(&rule, &map),
Err(VirtualMeterError::MissingSource(_))
));
}
#[test]
fn residual_rule_subtracts_generation() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let (kt, vt) = source("TOTAL", vec![(0, dec!(10.0)), (15, dec!(8.0))]);
let (kp, vp) = source("PV", vec![(0, dec!(3.0)), (15, dec!(2.0))]);
map.insert(kt, vt);
map.insert(kp, vp);
let rule = AggregationRule::Residual {
total_malo_id: "TOTAL".to_owned(),
subtract_malo_ids: vec!["PV".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].value, dec!(7.0));
assert_eq!(result[1].value, dec!(6.0));
}
#[test]
fn residual_can_produce_negative_for_net_exporter() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let (kt, vt) = source("GRID", vec![(0, dec!(1.0))]);
let (kp, vp) = source("PV", vec![(0, dec!(5.0))]);
map.insert(kt, vt);
map.insert(kp, vp);
let rule = AggregationRule::Residual {
total_malo_id: "GRID".to_owned(),
subtract_malo_ids: vec!["PV".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].value, dec!(-4.0));
}
#[test]
fn pv_self_consumption_net_grid() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let (kg, vg) = source("GRID", vec![(0, dec!(4.0))]);
let (kp, vp) = source("GEN", vec![(0, dec!(2.0))]);
map.insert(kg, vg);
map.insert(kp, vp);
let rule = AggregationRule::PvSelfConsumption {
grid_malo_id: "GRID".to_owned(),
generation_malo_id: "GEN".to_owned(),
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].value, dec!(2.0));
}
#[test]
fn ggv_constant_tenant_draws_residual_after_pv() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(10.0), QualityFlag::Measured)],
);
map.insert(
"T2".to_owned(),
vec![make_iv(ts(0), dec!(5.0), QualityFlag::Measured)],
);
let rule = AggregationRule::GgvConstantAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T2".to_owned(),
fraction: dec!(0.10),
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].value, dec!(4.0), "net grid draw = 5 - 1 = 4");
}
#[test]
fn ggv_constant_allocation_capped_by_tenant_consumption() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(10.0), QualityFlag::Measured)],
);
map.insert(
"T3".to_owned(),
vec![make_iv(ts(0), dec!(2.0), QualityFlag::Measured)],
);
let rule = AggregationRule::GgvConstantAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T3".to_owned(),
fraction: dec!(0.90),
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].value, dec!(0.0), "pos(2 - 9) = 0 — cap enforced");
}
#[test]
fn ggv_constant_feedin_balance_check() {
let plant_gen = dec!(10.0);
let t2_consumption = dec!(5.0);
let t3_consumption = dec!(20.0);
let t2_net = (t2_consumption - dec!(0.10) * plant_gen).max(Decimal::ZERO);
let t3_net = (t3_consumption - dec!(0.90) * plant_gen).max(Decimal::ZERO);
let pv_to_t2 = t2_consumption - t2_net;
let pv_to_t3 = t3_consumption - t3_net;
let grid_feedin = plant_gen - pv_to_t2 - pv_to_t3;
assert_eq!(t2_net, dec!(4.0));
assert_eq!(t3_net, dec!(11.0));
assert_eq!(pv_to_t2 + pv_to_t3, dec!(10.0));
assert_eq!(grid_feedin, dec!(0.0), "all PV consumed locally");
}
#[test]
fn ggv_constant_multiple_intervals() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![
make_iv(ts(0), dec!(10.0), QualityFlag::Measured),
make_iv(ts(15), dec!(0.0), QualityFlag::Measured),
],
);
map.insert(
"T".to_owned(),
vec![
make_iv(ts(0), dec!(3.0), QualityFlag::Measured),
make_iv(ts(15), dec!(3.0), QualityFlag::Measured),
],
);
let rule = AggregationRule::GgvConstantAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T".to_owned(),
fraction: dec!(0.5),
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].value, dec!(0.0), "3 - 5 < 0 → max(0)");
assert_eq!(result[1].value, dec!(3.0), "no PV → full load from grid");
}
#[test]
fn ggv_constant_invalid_fraction_rejected() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(1.0), QualityFlag::Measured)],
);
map.insert(
"T".to_owned(),
vec![make_iv(ts(0), dec!(1.0), QualityFlag::Measured)],
);
let rule = AggregationRule::GgvConstantAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T".to_owned(),
fraction: dec!(1.5), };
assert!(matches!(
compute_virtual_meter(&rule, &map),
Err(VirtualMeterError::InvalidFractions { .. })
));
}
#[test]
fn ggv_proportional_tenant_gets_consumption_weighted_pv() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(10.0), QualityFlag::Measured)],
);
map.insert(
"T2".to_owned(),
vec![make_iv(ts(0), dec!(2.0), QualityFlag::Measured)],
);
map.insert(
"T3".to_owned(),
vec![make_iv(ts(0), dec!(8.0), QualityFlag::Measured)],
);
let rule_t2 = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T2".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let rule_t3 = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T3".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let r2 = compute_virtual_meter(&rule_t2, &map).unwrap();
let r3 = compute_virtual_meter(&rule_t3, &map).unwrap();
assert_eq!(r2[0].value, dec!(0.0), "T2 fully covered by PV");
assert_eq!(r3[0].value, dec!(0.0), "T3 fully covered by PV");
}
#[test]
fn ggv_proportional_partial_coverage() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(6.0), QualityFlag::Measured)],
);
map.insert(
"T2".to_owned(),
vec![make_iv(ts(0), dec!(2.0), QualityFlag::Measured)],
);
map.insert(
"T3".to_owned(),
vec![make_iv(ts(0), dec!(8.0), QualityFlag::Measured)],
);
let rule_t2 = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T2".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let rule_t3 = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T3".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let r2 = compute_virtual_meter(&rule_t2, &map).unwrap();
let r3 = compute_virtual_meter(&rule_t3, &map).unwrap();
assert_eq!(r2[0].value, dec!(0.8));
assert_eq!(r3[0].value, dec!(3.2));
assert_eq!(
(dec!(2.0) - r2[0].value) + (dec!(8.0) - r3[0].value),
dec!(6.0)
);
}
#[test]
fn ggv_proportional_zero_division_guard() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(5.0), QualityFlag::Measured)],
);
map.insert(
"T2".to_owned(),
vec![make_iv(ts(0), dec!(0.0), QualityFlag::Measured)],
);
map.insert(
"T3".to_owned(),
vec![make_iv(ts(0), dec!(0.0), QualityFlag::Measured)],
);
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T2".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(
result[0].value,
dec!(0.0),
"zero total → zero draw (no division by zero)"
);
}
#[test]
fn ggv_proportional_cap_when_allocation_exceeds_consumption() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"PLANT".to_owned(),
vec![make_iv(ts(0), dec!(100.0), QualityFlag::Measured)],
);
map.insert(
"T2".to_owned(),
vec![make_iv(ts(0), dec!(1.0), QualityFlag::Measured)],
);
map.insert(
"T3".to_owned(),
vec![make_iv(ts(0), dec!(1.0), QualityFlag::Measured)],
);
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T2".to_owned(),
all_tenant_melo_ids: vec!["T2".to_owned(), "T3".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].value, dec!(0.0), "§42b cap: no negative draw");
}
#[test]
fn ggv_proportional_missing_source_returns_error() {
let map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T".to_owned(),
all_tenant_melo_ids: vec!["T".to_owned()],
};
assert!(matches!(
compute_virtual_meter(&rule, &map),
Err(VirtualMeterError::MissingSource(_))
));
}
#[test]
fn misaligned_timestamps_produce_intersection() {
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
let (ka, va) = source("A", vec![(0, dec!(1.0)), (15, dec!(1.0))]);
let (kb, vb) = source("B", vec![(15, dec!(2.0))]);
map.insert(ka, va);
map.insert(kb, vb);
let rule = AggregationRule::Sum {
source_malo_ids: vec!["A".to_owned(), "B".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result.len(), 1, "only ts=15 is in both series");
assert_eq!(result[0].value, dec!(3.0));
}
#[test]
fn worst_quality_propagates_in_sum() {
let base = ts(0);
let mut map: HashMap<String, Vec<MeterInterval>> = HashMap::new();
map.insert(
"A".to_owned(),
vec![make_iv(base, dec!(1.0), QualityFlag::Measured)],
);
map.insert(
"B".to_owned(),
vec![make_iv(base, dec!(1.0), QualityFlag::Estimated)],
);
let rule = AggregationRule::Sum {
source_malo_ids: vec!["A".to_owned(), "B".to_owned()],
};
let result = compute_virtual_meter(&rule, &map).unwrap();
assert_eq!(result[0].quality, QualityFlag::Estimated);
}
}