use std::collections::{HashMap, HashSet};
use std::hash::BuildHasher;
use rust_decimal::Decimal;
use time::OffsetDateTime;
use crate::aggregation_rule::{AggregationRule, VirtualMeterKind};
use crate::allocation::{
AllocationBasis, AllocationError, AllocationPart, allocate, allocation_share, validate_key,
};
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(transparent)]
Allocation(#[from] AllocationError),
#[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 {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub consumption: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub generation: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub share: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub allocated: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::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 = crate::allocation::cap(share, Some(consumption));
(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])?;
validate_key(
&[AllocationPart::new(tenant_id, fraction)],
AllocationBasis::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 = allocation_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 = allocation_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(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(tag = "kind", rename_all = "SCREAMING_SNAKE_CASE")
)]
pub enum AllocationKey {
Constant {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal_map"))]
fractions: std::collections::BTreeMap<String, Decimal>,
},
Proportional {
participants: Vec<String>,
},
}
impl AllocationKey {
#[must_use]
pub fn participant_ids(&self) -> Vec<&str> {
match self {
Self::Constant { fractions } => fractions.keys().map(String::as_str).collect(),
Self::Proportional { participants } => {
let mut ids: Vec<&str> = participants.iter().map(String::as_str).collect();
ids.sort_unstable();
ids.dedup();
ids
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParticipantAllocation {
pub id: String,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub consumption: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub share: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub allocated: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub net_grid_draw: Decimal,
}
impl ParticipantAllocation {
#[must_use]
pub fn capped(&self) -> bool {
self.share > self.allocated
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommunityInterval {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub from: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::rfc3339"))]
pub to: OffsetDateTime,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub generation: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub total_consumption: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub pool_cap: Decimal,
pub participants: Vec<ParticipantAllocation>,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub surplus_to_grid: Decimal,
pub quality: QualityFlag,
}
impl CommunityInterval {
#[must_use]
pub fn total_allocated(&self) -> Decimal {
self.participants.iter().map(|p| p.allocated).sum()
}
#[must_use]
pub fn total_net_grid_draw(&self) -> Decimal {
self.participants.iter().map(|p| p.net_grid_draw).sum()
}
#[must_use]
pub fn participant(&self, id: &str) -> Option<&ParticipantAllocation> {
self.participants.iter().find(|p| p.id == id)
}
}
pub fn compute_community_allocation<S: BuildHasher>(
plant_id: &str,
key: &AllocationKey,
sources: &SourceMap<S>,
) -> Result<Vec<CommunityInterval>, VirtualMeterError> {
let ids = key.participant_ids();
require(sources, std::iter::once(plant_id))?;
require(sources, ids.iter().copied())?;
if let AllocationKey::Constant { fractions } = key {
let parts: Vec<AllocationPart> = fractions
.iter()
.map(|(id, fraction)| AllocationPart::new(id.clone(), *fraction))
.collect();
validate_key(&parts, AllocationBasis::Fraction)?;
}
let index = SourceIndex::build(sources);
let aligned = aligned_timestamps(
std::iter::once(plant_id).chain(ids.iter().copied()),
sources,
);
let mut out = Vec::with_capacity(aligned.len());
for ts in aligned {
let Some(plant_iv) = index.get(plant_id, ts) else {
continue;
};
let Some(consumption): Option<Vec<(&str, &MeterInterval)>> = ids
.iter()
.map(|id| index.get(id, ts).map(|iv| (*id, iv)))
.collect()
else {
continue;
};
let total_consumption: Decimal = consumption.iter().map(|(_, iv)| iv.value).sum();
let generation = plant_iv.value;
let (basis, parts) = match key {
AllocationKey::Constant { fractions } => (
AllocationBasis::Fraction,
consumption
.iter()
.map(|(id, iv)| {
AllocationPart::new(
*id,
fractions.get(*id).copied().unwrap_or(Decimal::ZERO),
)
.capped_at(iv.value)
})
.collect(),
),
AllocationKey::Proportional { .. } => (
AllocationBasis::Proportional,
consumption
.iter()
.map(|(id, iv)| AllocationPart::new(*id, iv.value).capped_at(iv.value))
.collect(),
),
};
let row = allocate(generation, parts, basis)?;
let participants: Vec<ParticipantAllocation> = row
.parts
.into_iter()
.zip(consumption.iter())
.map(|(part, (_, iv))| ParticipantAllocation {
id: part.key,
consumption: iv.value,
share: part.share,
allocated: part.allocated,
net_grid_draw: iv.value - part.allocated,
})
.collect();
let quality = consumption
.iter()
.fold(plant_iv.quality, |q, (_, iv)| q.worse_of(iv.quality));
out.push(CommunityInterval {
from: ts,
to: plant_iv.to,
generation,
total_consumption,
pool_cap: generation.min(total_consumption),
participants,
surplus_to_grid: row.residual,
quality,
});
}
Ok(out)
}
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::Allocation(
AllocationError::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!(total_share <= dec!(9), "never over the generation");
assert!(
dec!(9) - total_share < dec!(0.00001),
"and within a hundredth of a milliwatt-hour of it: {total_share}"
);
assert_eq!(total_share, dec!(8.999999));
}
#[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::Allocation(
AllocationError::InvalidFractions { .. }
))
));
}
assert!(compute_ggv_allocation(&constant(dec!(1)), &src).is_ok());
}
}
#[cfg(test)]
mod community_tests {
use super::*;
use rust_decimal::dec;
use std::collections::{BTreeMap, HashMap};
use time::{Duration, macros::datetime};
const BASE: OffsetDateTime = datetime!(2026-06-01 12:00 UTC);
fn series(values: &[Decimal]) -> Vec<MeterInterval> {
values
.iter()
.enumerate()
.map(|(i, &value)| {
let from = BASE + Duration::minutes(15 * i as i64);
MeterInterval {
from,
to: from + Duration::minutes(15),
value,
quality: QualityFlag::Measured,
obis_code: None,
}
})
.collect()
}
fn community() -> SourceMap {
let mut sources: SourceMap = HashMap::new();
sources.insert("PLANT".to_owned(), series(&[dec!(10), dec!(2), dec!(0)]));
sources.insert("T1".to_owned(), series(&[dec!(1), dec!(4), dec!(2)]));
sources.insert("T2".to_owned(), series(&[dec!(3), dec!(4), dec!(6)]));
sources
}
fn proportional() -> AllocationKey {
AllocationKey::Proportional {
participants: vec!["T1".to_owned(), "T2".to_owned()],
}
}
fn constant(t1: Decimal, t2: Decimal) -> AllocationKey {
AllocationKey::Constant {
fractions: BTreeMap::from([("T1".to_owned(), t1), ("T2".to_owned(), t2)]),
}
}
#[test]
fn the_allocation_identities_hold_exactly() {
let sources = community();
for key in [proportional(), constant(dec!(0.25), dec!(0.5))] {
let out = compute_community_allocation("PLANT", &key, &sources).unwrap();
assert_eq!(out.len(), 3);
for iv in &out {
for p in &iv.participants {
assert_eq!(p.consumption, p.allocated + p.net_grid_draw, "{p:?}");
assert!(p.allocated <= p.consumption);
assert!(p.allocated <= p.share);
}
assert_eq!(
iv.generation,
iv.total_allocated() + iv.surplus_to_grid,
"generation splits into credited and exported",
);
assert_eq!(
iv.total_consumption,
iv.total_allocated() + iv.total_net_grid_draw(),
);
}
}
}
#[test]
fn the_pool_never_exceeds_the_paragraph_42b_ceiling() {
let sources = community();
for key in [
proportional(),
constant(dec!(0.25), dec!(0.5)),
constant(dec!(0.5), dec!(0.5)), ] {
for iv in compute_community_allocation("PLANT", &key, &sources).unwrap() {
assert_eq!(iv.pool_cap, iv.generation.min(iv.total_consumption));
assert!(
iv.total_allocated() <= iv.pool_cap,
"allocated {} over the cap {} at {}",
iv.total_allocated(),
iv.pool_cap,
iv.from,
);
}
}
}
#[test]
fn the_community_view_agrees_with_the_per_tenant_one() {
let sources = community();
let community = compute_community_allocation("PLANT", &proportional(), &sources).unwrap();
for tenant in ["T1", "T2"] {
let rule = AggregationRule::GgvProportionalAllocation {
plant_melo_id: "PLANT".to_owned(),
tenant_melo_id: tenant.to_owned(),
all_tenant_melo_ids: vec!["T1".to_owned(), "T2".to_owned()],
};
let per_tenant = compute_ggv_allocation(&rule, &sources).unwrap();
assert_eq!(per_tenant.len(), community.len());
for (single, whole) in per_tenant.iter().zip(&community) {
let p = whole.participant(tenant).expect("named participant");
assert_eq!(single.from, whole.from);
assert_eq!(single.consumption, p.consumption);
assert_eq!(single.share, p.share);
assert_eq!(single.allocated, p.allocated);
assert_eq!(single.net_grid_draw, p.net_grid_draw);
assert_eq!(single.capped(), p.capped());
}
}
}
#[test]
fn constant_fractions_must_not_over_subscribe_the_plant() {
let sources = community();
let over = compute_community_allocation("PLANT", &constant(dec!(0.7), dec!(0.5)), &sources);
assert_eq!(
over,
Err(VirtualMeterError::Allocation(
AllocationError::InvalidFractions { sum: dec!(1.2) }
)),
);
assert!(
compute_community_allocation("PLANT", &constant(dec!(0), dec!(0.5)), &sources).is_err()
);
assert!(
compute_community_allocation("PLANT", &constant(dec!(0.5), dec!(0.5)), &sources)
.is_ok()
);
}
#[test]
fn an_incomplete_interval_is_skipped_whole() {
let mut sources = community();
sources.insert("T2".to_owned(), series(&[dec!(3)])); let out = compute_community_allocation("PLANT", &proportional(), &sources).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].total_consumption, dec!(4));
}
#[test]
fn a_missing_series_is_named() {
let sources = community();
let key = AllocationKey::Proportional {
participants: vec!["T1".to_owned(), "GHOST".to_owned()],
};
assert_eq!(
compute_community_allocation("PLANT", &key, &sources),
Err(VirtualMeterError::MissingSource("GHOST".to_owned())),
);
assert_eq!(
compute_community_allocation("NO_PLANT", &proportional(), &sources),
Err(VirtualMeterError::MissingSource("NO_PLANT".to_owned())),
);
}
#[test]
fn a_dead_interval_allocates_nothing() {
let mut sources: SourceMap = HashMap::new();
sources.insert("PLANT".to_owned(), series(&[dec!(0)]));
sources.insert("T1".to_owned(), series(&[dec!(0)]));
sources.insert("T2".to_owned(), series(&[dec!(0)]));
let out = compute_community_allocation("PLANT", &proportional(), &sources).unwrap();
assert_eq!(out[0].pool_cap, Decimal::ZERO);
assert_eq!(out[0].total_allocated(), Decimal::ZERO);
assert_eq!(out[0].surplus_to_grid, Decimal::ZERO);
}
#[test]
fn the_participant_order_does_not_change_the_result() {
let sources = community();
let forward = compute_community_allocation("PLANT", &proportional(), &sources).unwrap();
let reversed = AllocationKey::Proportional {
participants: vec!["T2".to_owned(), "T1".to_owned()],
};
let backward = compute_community_allocation("PLANT", &reversed, &sources).unwrap();
assert_eq!(forward, backward);
}
#[test]
fn a_substituted_tenant_reading_downgrades_the_whole_interval() {
let mut sources = community();
let mut t2 = series(&[dec!(3), dec!(4), dec!(6)]);
t2[1].quality = QualityFlag::Substituted;
sources.insert("T2".to_owned(), t2);
let out = compute_community_allocation("PLANT", &proportional(), &sources).unwrap();
assert_eq!(out[0].quality, QualityFlag::Measured);
assert_eq!(out[1].quality, QualityFlag::Substituted);
}
}
#[cfg(test)]
mod allocation_precision_tests {
use super::*;
use rust_decimal::dec;
use std::collections::HashMap;
use time::macros::datetime;
fn one(value: Decimal) -> Vec<MeterInterval> {
vec![MeterInterval {
from: datetime!(2026-06-01 12:00 UTC),
to: datetime!(2026-06-01 12:15 UTC),
value,
quality: QualityFlag::Measured,
obis_code: None,
}]
}
#[test]
fn a_repeating_share_does_not_break_the_split_identity() {
let mut sources: SourceMap = HashMap::new();
sources.insert("PLANT".to_owned(), one(dec!(10)));
for t in ["T1", "T2", "T3"] {
sources.insert(t.to_owned(), one(dec!(40)));
}
let key = AllocationKey::Proportional {
participants: vec!["T1".to_owned(), "T2".to_owned(), "T3".to_owned()],
};
let out = compute_community_allocation("PLANT", &key, &sources).unwrap();
let iv = &out[0];
for p in &iv.participants {
assert_eq!(
p.consumption,
p.allocated + p.net_grid_draw,
"{}: {} != {} + {}",
p.id,
p.consumption,
p.allocated,
p.net_grid_draw,
);
assert!(
p.share.scale() <= crate::allocation::ALLOCATION_DP,
"share {} is not",
p.share
);
assert_eq!(p.share, dec!(3.333333), "10 ÷ 3, cut toward zero");
}
assert_eq!(iv.total_consumption, dec!(120));
assert_eq!(iv.total_allocated(), dec!(9.999999));
assert_eq!(iv.surplus_to_grid, dec!(0.000001));
assert!(iv.total_allocated() <= iv.pool_cap);
}
#[test]
fn a_fully_covered_participant_nets_exactly_zero() {
let mut sources: SourceMap = HashMap::new();
sources.insert("PLANT".to_owned(), one(dec!(100)));
sources.insert("T1".to_owned(), one(dec!(1.2345678)));
let key = AllocationKey::Proportional {
participants: vec!["T1".to_owned()],
};
let p = &compute_community_allocation("PLANT", &key, &sources).unwrap()[0].participants[0];
assert_eq!(
p.allocated,
dec!(1.2345678),
"credited exactly what was drawn"
);
assert_eq!(p.net_grid_draw, Decimal::ZERO);
assert!(p.capped());
}
}