use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::aggregation_rule::{AggregationRule, VirtualMeterKind};
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,
},
#[error("{kind} is not a GGV allocation rule — it has no allocated amount")]
NotAGgvRule {
kind: VirtualMeterKind,
},
}
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 { .. }
| AggregationRule::GgvProportionalAllocation { .. } => {
Ok(compute_ggv_allocation(rule, sources)?
.iter()
.map(GgvInterval::to_net_interval)
.collect())
}
}
}
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(end.map_or(iv.to, |e: OffsetDateTime| e.max(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)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GgvInterval {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub consumption: Decimal,
pub generation: Decimal,
pub share: Decimal,
pub allocated: Decimal,
pub net_grid_draw: Decimal,
pub quality: QualityFlag,
}
impl GgvInterval {
#[must_use]
pub fn capped(&self) -> bool {
self.share > self.allocated
}
#[must_use]
pub fn surplus_to_grid(&self) -> Decimal {
self.share - self.allocated
}
#[must_use]
pub fn to_net_interval(&self) -> MeterInterval {
MeterInterval {
from: self.from,
to: self.to,
value: self.net_grid_draw,
quality: self.quality,
obis_code: None,
}
}
}
pub fn compute_ggv_allocation<S: BuildHasher>(
rule: &AggregationRule,
sources: &SourceMap<S>,
) -> Result<Vec<GgvInterval>, VirtualMeterError> {
match rule {
AggregationRule::GgvConstantAllocation {
plant_melo_id,
tenant_melo_id,
fraction,
} => ggv_constant(plant_melo_id, tenant_melo_id, *fraction, sources),
AggregationRule::GgvProportionalAllocation {
plant_melo_id,
tenant_melo_id,
all_tenant_melo_ids,
} => ggv_proportional(plant_melo_id, tenant_melo_id, all_tenant_melo_ids, sources),
other => Err(VirtualMeterError::NotAGgvRule { kind: other.kind() }),
}
}
fn ggv_split(consumption: Decimal, share: Decimal) -> (Decimal, Decimal) {
let allocated = consumption.min(share).max(Decimal::ZERO);
(allocated, consumption - allocated)
}
fn ggv_constant<S: BuildHasher>(
plant_id: &str,
tenant_id: &str,
fraction: Decimal,
sources: &SourceMap<S>,
) -> Result<Vec<GgvInterval>, VirtualMeterError> {
require(sources, [plant_id, tenant_id])?;
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 share = fraction * plant_iv.value;
let (allocated, net_grid_draw) = ggv_split(tenant_iv.value, share);
result.push(GgvInterval {
from: ts,
to: tenant_iv.to,
consumption: tenant_iv.value,
generation: plant_iv.value,
share,
allocated,
net_grid_draw,
quality: plant_iv.quality.worse_of(tenant_iv.quality),
});
}
Ok(result)
}
fn ggv_proportional<S: BuildHasher>(
plant_id: &str,
tenant_id: &str,
all_tenant_ids: &[String],
sources: &SourceMap<S>,
) -> Result<Vec<GgvInterval>, VirtualMeterError> {
require(sources, [plant_id, tenant_id])?;
require(sources, all_tenant_ids.iter().map(String::as_str))?;
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) = all_tenant_ids
.iter()
.map(|id| index.get(id, ts).map(|iv| iv.value))
.sum::<Option<Decimal>>()
else {
continue;
};
let share = if total > Decimal::ZERO {
tenant_iv.value / total * plant_iv.value
} else {
Decimal::ZERO
};
let (allocated, net_grid_draw) = ggv_split(tenant_iv.value, share);
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(GgvInterval {
from: ts,
to: tenant_iv.to,
consumption: tenant_iv.value,
generation: plant_iv.value,
share,
allocated,
net_grid_draw,
quality,
});
}
Ok(result)
}
fn require<'a, S: BuildHasher>(
sources: &SourceMap<S>,
ids: impl IntoIterator<Item = &'a str>,
) -> Result<(), VirtualMeterError> {
for id in ids {
if !sources.contains_key(id) {
return Err(VirtualMeterError::MissingSource(id.to_owned()));
}
}
Ok(())
}
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);
}
}
#[cfg(test)]
mod ggv_allocation_tests {
use super::*;
use rust_decimal::dec;
use time::{Duration, macros::datetime};
fn series(values: &[Decimal]) -> Vec<MeterInterval> {
values
.iter()
.enumerate()
.map(|(i, &value)| {
let from = datetime!(2026-06-01 12:00 UTC) + Duration::minutes(15 * i as i64);
MeterInterval {
from,
to: from + Duration::minutes(15),
value,
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
fn sources(pairs: &[(&str, &[Decimal])]) -> SourceMap {
pairs
.iter()
.map(|(id, vals)| ((*id).to_owned(), series(vals)))
.collect()
}
fn constant(fraction: Decimal) -> AggregationRule {
AggregationRule::GgvConstantAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T1".to_owned(),
fraction,
}
}
#[test]
fn consumption_is_exactly_allocated_plus_net() {
let src = sources(&[
("PLANT", &[dec!(10), dec!(0), dec!(4), dec!(7.3)]),
("T1", &[dec!(1), dec!(3), dec!(4), dec!(2.9)]),
]);
let out = compute_ggv_allocation(&constant(dec!(0.5)), &src).unwrap();
assert_eq!(out.len(), 4);
for iv in &out {
assert_eq!(
iv.consumption,
iv.allocated + iv.net_grid_draw,
"at {}",
iv.from
);
assert!(iv.allocated >= Decimal::ZERO && iv.net_grid_draw >= Decimal::ZERO);
assert!(iv.allocated <= iv.share, "the cap never credits more");
}
}
#[test]
fn capping_and_the_surplus_that_fed_the_grid() {
let src = sources(&[("PLANT", &[dec!(10)]), ("T1", &[dec!(1)])]);
let capped = &compute_ggv_allocation(&constant(dec!(0.5)), &src).unwrap()[0];
assert_eq!(capped.share, dec!(5.0));
assert_eq!(capped.allocated, dec!(1));
assert_eq!(capped.net_grid_draw, dec!(0));
assert!(capped.capped());
assert_eq!(capped.surplus_to_grid(), dec!(4.0));
let src = sources(&[("PLANT", &[dec!(10)]), ("T1", &[dec!(8)])]);
let short = &compute_ggv_allocation(&constant(dec!(0.5)), &src).unwrap()[0];
assert_eq!(short.allocated, dec!(5.0));
assert_eq!(short.net_grid_draw, dec!(3.0));
assert!(!short.capped());
assert_eq!(short.surplus_to_grid(), Decimal::ZERO);
let src = sources(&[("PLANT", &[dec!(10)]), ("T1", &[dec!(5)])]);
let exact = &compute_ggv_allocation(&constant(dec!(0.5)), &src).unwrap()[0];
assert!(!exact.capped());
assert_eq!(exact.net_grid_draw, Decimal::ZERO);
}
#[test]
fn the_proportional_shares_exhaust_the_generation() {
let src = sources(&[
("PLANT", &[dec!(9)]),
("T1", &[dec!(2)]),
("T2", &[dec!(4)]),
("T3", &[dec!(6)]),
]);
let tenants: Vec<String> = ["T1", "T2", "T3"].iter().map(|s| (*s).to_owned()).collect();
let mut total_share = Decimal::ZERO;
for tenant in &tenants {
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: tenant.clone(),
all_tenant_melo_ids: tenants.clone(),
};
let iv = &compute_ggv_allocation(&rule, &src).unwrap()[0];
assert_eq!(iv.generation, dec!(9));
assert!(!iv.capped(), "{tenant}");
assert_eq!(iv.allocated, iv.share);
total_share += iv.share;
}
assert_eq!(total_share, dec!(9), "the shares exhaust the generation");
}
#[test]
fn a_zero_denominator_allocates_nothing() {
let src = sources(&[
("PLANT", &[dec!(5)]),
("T1", &[dec!(0)]),
("T2", &[dec!(0)]),
]);
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: "T1".to_owned(),
all_tenant_melo_ids: vec!["T1".to_owned(), "T2".to_owned()],
};
let iv = &compute_ggv_allocation(&rule, &src).unwrap()[0];
assert_eq!(iv.share, Decimal::ZERO);
assert_eq!(iv.allocated, Decimal::ZERO);
assert_eq!(iv.net_grid_draw, Decimal::ZERO);
assert_eq!(iv.consumption, iv.allocated + iv.net_grid_draw);
}
#[test]
fn the_net_series_is_a_projection_of_the_allocation() {
let src = sources(&[
("PLANT", &[dec!(10), dec!(2), dec!(0)]),
("T1", &[dec!(1), dec!(3), dec!(4)]),
]);
let rule = constant(dec!(0.5));
let net = compute_virtual_meter(&rule, &src).unwrap();
let full = compute_ggv_allocation(&rule, &src).unwrap();
assert_eq!(net.len(), full.len());
for (n, f) in net.iter().zip(&full) {
assert_eq!(n, &f.to_net_interval());
assert_eq!(n.value, f.net_grid_draw);
}
}
#[test]
fn the_worst_contributor_sets_the_quality() {
let mut src = sources(&[("PLANT", &[dec!(10)]), ("T1", &[dec!(8)])]);
src.get_mut("PLANT").unwrap()[0].quality = QualityFlag::Estimated;
let iv = &compute_ggv_allocation(&constant(dec!(0.5)), &src).unwrap()[0];
assert_eq!(iv.quality, QualityFlag::Estimated);
assert_eq!(iv.to_net_interval().quality, QualityFlag::Estimated);
}
#[test]
fn a_non_ggv_rule_is_refused_by_name() {
let src = sources(&[("A", &[dec!(1)]), ("B", &[dec!(2)])]);
let err = compute_ggv_allocation(
&AggregationRule::Sum {
source_malo_ids: vec!["A".to_owned(), "B".to_owned()],
},
&src,
)
.unwrap_err();
assert_eq!(
err,
VirtualMeterError::NotAGgvRule {
kind: VirtualMeterKind::Sum
}
);
assert!(err.to_string().contains("SUM"), "{err}");
}
#[test]
fn missing_sources_and_bad_fractions_are_refused() {
let src = sources(&[("PLANT", &[dec!(10)])]);
assert!(matches!(
compute_ggv_allocation(&constant(dec!(0.5)), &src),
Err(VirtualMeterError::MissingSource(id)) if id == "T1"
));
let src = sources(&[("PLANT", &[dec!(10)]), ("T1", &[dec!(1)])]);
for bad in [dec!(0), dec!(-0.1), dec!(1.5)] {
assert!(matches!(
compute_ggv_allocation(&constant(bad), &src),
Err(VirtualMeterError::InvalidFractions { .. })
));
}
assert!(compute_ggv_allocation(&constant(dec!(1)), &src).is_ok());
}
}