use rust_decimal::Decimal;
use std::collections::HashMap;
use time::OffsetDateTime;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MeterInput {
#[serde(default)]
pub arbeitsmenge_kwh: Decimal,
#[serde(default)]
pub arbeitsmenge_ht_kwh: Option<Decimal>,
#[serde(default)]
pub arbeitsmenge_nt_kwh: Option<Decimal>,
#[serde(default)]
pub spitzenleistung_kw: Option<Decimal>,
#[serde(default)]
pub steuerung_stunden: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct GasMeterInput {
pub messung_qm3: Decimal,
#[serde(default)]
pub brennwert_kwh_per_qm3: Option<Decimal>,
#[serde(default)]
pub zustandszahl: Option<Decimal>,
#[serde(default)]
pub kwh_hs: Option<Decimal>,
#[serde(default)]
pub gasqualitaet: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct WaermeMeterInput {
#[serde(default)]
pub kwh_waerme: Decimal,
#[serde(default)]
pub spitzenleistung_kw: Option<Decimal>,
#[serde(default)]
pub months: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct SolarMeterInput {
pub eigenverbrauch_kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GgvNutzungsplanEntry {
pub malo_id: String,
pub fraction: Decimal,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GgvNutzungsplan(pub Vec<GgvNutzungsplanEntry>);
impl GgvNutzungsplan {
pub fn validate(&self) -> Result<(), String> {
use rust_decimal_macros::dec;
if self.0.is_empty() {
return Err("GGV Nutzungsplan must have at least one entry".to_owned());
}
for e in &self.0 {
if e.fraction <= Decimal::ZERO {
return Err(format!(
"GGV Nutzungsplan: fraction for {} must be > 0, got {}",
e.malo_id, e.fraction
));
}
}
let total: Decimal = self.0.iter().map(|e| e.fraction).sum();
let diff = (total - Decimal::ONE).abs();
if diff > dec!(0.001) {
return Err(format!(
"GGV Nutzungsplan: fractions sum to {total}, must be 1.0 (±0.001)"
));
}
Ok(())
}
pub fn allocate(&self, total_kwh: Decimal) -> Vec<(String, Decimal)> {
let n = self.0.len();
if n == 0 || total_kwh <= Decimal::ZERO {
return vec![];
}
let scale = Decimal::from(1_000u32); let unit = Decimal::ONE / scale;
let mut entries: Vec<(String, Decimal, Decimal)> = self
.0
.iter()
.map(|e| {
let exact = total_kwh * e.fraction;
let floored = (exact * scale).trunc() / scale;
let fractional = exact - floored;
(e.malo_id.clone(), floored, fractional)
})
.collect();
let sum_floor: Decimal = entries.iter().map(|(_, f, _)| *f).sum();
let leftover_units = ((total_kwh - sum_floor) * scale)
.round()
.try_into()
.unwrap_or(0u64) as usize;
let mut order: Vec<usize> = (0..n).collect();
order.sort_unstable_by(|&a, &b| entries[b].2.cmp(&entries[a].2));
for i in order.iter().take(leftover_units) {
entries[*i].1 += unit;
}
entries.into_iter().map(|(id, kwh, _)| (id, kwh)).collect()
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EegMeterInput {
pub einspeisung_kwh: Decimal,
#[serde(default)]
pub kwh_during_negative_epex: Option<Decimal>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct HemsMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub optimization_events: Option<u32>,
#[serde(default)]
pub readout_events: Option<u32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct EmobilityMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub kwh_charged: Option<Decimal>,
#[serde(default)]
pub sessions: Option<u32>,
#[serde(default)]
pub roaming_sessions: Option<u32>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ServiceMeterInput {
#[serde(default)]
pub months: Option<Decimal>,
#[serde(default)]
pub event_count: Option<u32>,
#[serde(default)]
pub event_price_eur: Option<Decimal>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DynamicInterval {
pub timestamp_utc: OffsetDateTime,
pub kwh: Decimal,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct GridInput {
#[serde(default)]
pub nne_grundpreis_eur_per_year: Option<Decimal>,
#[serde(default)]
pub nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub nne_leistungspreis_eur_per_kw_year: Option<Decimal>,
#[serde(default)]
pub ka_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_nne_grundpreis_eur_per_year: Option<Decimal>,
#[serde(default)]
pub gas_nne_arbeitspreis_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_ka_ct_per_kwh: Option<Decimal>,
#[serde(default)]
pub gas_bilanzierungsumlage_ct_per_kwh: Option<Decimal>,
}
#[derive(Debug, Clone, Default)]
pub struct Quantities {
pub electricity: Option<MeterInput>,
pub gas: Option<GasMeterInput>,
pub heat: Option<WaermeMeterInput>,
pub solar: Option<SolarMeterInput>,
pub eeg: Option<EegMeterInput>,
pub eeg_full: Option<eeg_billing::SettleInput>,
pub einspeisung: Option<EegMeterInput>,
pub hems: Option<HemsMeterInput>,
pub emobility: Option<EmobilityMeterInput>,
pub service: Option<ServiceMeterInput>,
pub dynamic_intervals: Vec<DynamicInterval>,
pub dynamic_epex_prices: HashMap<(i32, u8, u8, u8), Decimal>,
pub eeg_gutschrift_eur: Option<Decimal>,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn plan(fractions: &[(&str, &str)]) -> GgvNutzungsplan {
GgvNutzungsplan(
fractions
.iter()
.map(|(id, f)| GgvNutzungsplanEntry {
malo_id: (*id).to_owned(),
fraction: f.parse().unwrap(),
})
.collect(),
)
}
#[test]
fn allocate_sum_equals_total() {
let p = plan(&[("A", "0.333"), ("B", "0.333"), ("C", "0.334")]);
let total = dec!(100.000);
let allocs = p.allocate(total);
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total, "sum must equal total exactly");
}
#[test]
fn allocate_lrm_distributes_evenly_not_just_last_entry() {
let p = plan(&[("A", "0.3333"), ("B", "0.3333"), ("C", "0.3334")]);
let total = dec!(100.000);
let allocs = p.allocate(total);
for (id, kwh) in &allocs {
let fraction: Decimal = p.0.iter().find(|e| &e.malo_id == id).unwrap().fraction;
let exact = total * fraction;
let diff = (kwh - exact).abs();
assert!(
diff <= dec!(0.001),
"{id}: allocated {kwh}, exact {exact}, diff {diff} > 0.001"
);
}
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total);
}
#[test]
fn allocate_lrm_no_disproportionate_last_entry() {
let tenants: Vec<(String, String)> = (0..10)
.map(|i| (format!("T{i}"), "0.1".to_owned()))
.collect();
let p = GgvNutzungsplan(
tenants
.iter()
.map(|(id, f)| GgvNutzungsplanEntry {
malo_id: id.clone(),
fraction: f.parse().unwrap(),
})
.collect(),
);
let total = dec!(1000.001);
let allocs = p.allocate(total);
let over_base: Vec<_> = allocs.iter().filter(|(_, k)| *k > dec!(100.000)).collect();
assert_eq!(
over_base.len(),
1,
"exactly 1 tenant should get the extra 0.001"
);
let sum: Decimal = allocs.iter().map(|(_, k)| k).sum();
assert_eq!(sum, total);
}
}