use rust_decimal::Decimal;
use std::collections::HashMap;
use time::OffsetDateTime;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum MeteringMode {
#[default]
Slp,
Rlm,
Imsys,
}
#[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>,
#[serde(default)]
pub zaehlernummer: Option<String>,
#[serde(default)]
pub zaehlerstand_von: Option<Decimal>,
#[serde(default)]
pub zaehlerstand_bis: Option<Decimal>,
#[serde(default)]
pub metering_mode: MeteringMode,
#[serde(default)]
pub is_estimated: bool,
#[serde(default)]
pub zaehler_replaced: bool,
}
#[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)> {
if self.0.is_empty() || total_kwh <= Decimal::ZERO {
return vec![];
}
let fractions: Vec<Decimal> = self.0.iter().map(|e| e.fraction).collect();
let parts = billing::proportional_split(total_kwh, &fractions, 3)
.expect("fractions non-empty — checked above");
self.0
.iter()
.zip(parts)
.map(|(e, kwh)| (e.malo_id.clone(), 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, serde::Serialize, serde::Deserialize)]
pub struct Sect41aAnnualComparison {
pub actual_kwh: Decimal,
pub actual_eur_brutto: Decimal,
pub reference_price_ct_per_kwh: Decimal,
pub reference_eur_brutto: Decimal,
pub savings_eur: Decimal,
}
impl Sect41aAnnualComparison {
#[must_use]
pub fn compute(
actual_kwh: Decimal,
actual_eur_brutto: Decimal,
reference_price_ct_per_kwh: Decimal,
) -> Self {
use rust_decimal_macros::dec;
let reference_eur_brutto =
(actual_kwh * reference_price_ct_per_kwh / dec!(100)).round_dp(2);
let savings_eur = (reference_eur_brutto - actual_eur_brutto).round_dp(2);
Self {
actual_kwh,
actual_eur_brutto,
reference_price_ct_per_kwh,
reference_eur_brutto,
savings_eur,
}
}
}
#[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 ggv_solar: Option<GgvSolarInput>,
pub eeg: Option<EegMeterInput>,
#[cfg(feature = "eeg")]
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>,
pub prosumer: Option<ProsumerMeterInput>,
pub sect41a_annual_comparison: Option<Sect41aAnnualComparison>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ProsumerMeterInput {
pub grid_consumption_kwh: Decimal,
pub self_consumption_kwh: Decimal,
#[serde(default)]
pub export_kwh: Option<Decimal>,
}
impl ProsumerMeterInput {
#[must_use]
pub fn total_consumption_kwh(&self) -> Decimal {
self.grid_consumption_kwh + self.self_consumption_kwh
}
#[must_use]
pub fn self_supply_ratio(&self) -> Decimal {
let total = self.total_consumption_kwh();
if total.is_zero() {
Decimal::ZERO
} else {
(self.self_consumption_kwh / total).min(Decimal::ONE)
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GgvSolarInput {
pub pv_allocated_kwh: Decimal,
pub actual_consumption_kwh: Decimal,
}
impl GgvSolarInput {
#[must_use]
pub fn pv_delivered_kwh(&self) -> Decimal {
self.actual_consumption_kwh.min(self.pv_allocated_kwh)
}
#[must_use]
pub fn grid_kwh(&self) -> Decimal {
(self.actual_consumption_kwh - self.pv_allocated_kwh).max(Decimal::ZERO)
}
#[must_use]
pub fn pv_coverage_ratio(&self) -> Decimal {
if self.actual_consumption_kwh <= Decimal::ZERO {
return Decimal::ZERO;
}
(self.pv_delivered_kwh() / self.actual_consumption_kwh)
.min(Decimal::ONE)
.round_dp(4)
}
}
#[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);
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AbschlagsplanEntry {
pub faellig_am: time::Date,
pub betrag_eur: Decimal,
#[serde(default)]
pub beschreibung: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Abschlagsplan {
pub malo_id: String,
#[serde(default)]
pub contract_id: Option<String>,
pub entries: Vec<AbschlagsplanEntry>,
pub jahresverbrauch_schaetzung_kwh: Decimal,
pub jahreskosten_schaetzung_eur: Decimal,
}
impl Abschlagsplan {
#[must_use]
pub fn monthly_uniform(
malo_id: impl Into<String>,
start_date: time::Date,
months: u32,
annual_brutto_eur: Decimal,
jahresverbrauch_kwh: Decimal,
) -> Self {
use rust_decimal_macros::dec;
let monthly = (annual_brutto_eur / dec!(12)).round_dp(2);
let entries = (0..months)
.filter_map(|i| {
let total_months = start_date.month() as u32 - 1 + i;
let year = start_date.year() + (total_months / 12) as i32;
let month_idx = (total_months % 12 + 1) as u8;
let month = time::Month::try_from(month_idx).ok()?;
let max_day = month.length(time::util::is_leap_year(year) as i32) as u8;
let day = start_date.day().min(max_day);
let date = time::Date::from_calendar_date(year, month, day).ok()?;
Some(AbschlagsplanEntry {
faellig_am: date,
betrag_eur: monthly,
beschreibung: Some(format!("Abschlag {:02}/{}", month as u8, year)),
})
})
.collect();
Self {
malo_id: malo_id.into(),
contract_id: None,
entries,
jahresverbrauch_schaetzung_kwh: jahresverbrauch_kwh,
jahreskosten_schaetzung_eur: annual_brutto_eur,
}
}
#[must_use]
pub fn total_eur(&self) -> Decimal {
self.entries.iter().map(|e| e.betrag_eur).sum()
}
}
#[cfg(test)]
mod abschlagsplan_tests {
use super::*;
use rust_decimal_macros::dec;
use time::macros::date;
#[test]
fn monthly_uniform_12_months() {
let plan = Abschlagsplan::monthly_uniform(
"51238696781",
date!(2026 - 01 - 01),
12,
dec!(1440.00),
dec!(3600),
);
assert_eq!(plan.entries.len(), 12);
assert_eq!(plan.entries[0].betrag_eur, dec!(120.00));
assert_eq!(plan.entries[11].faellig_am.year(), 2026);
assert_eq!(plan.total_eur(), dec!(1440.00));
}
#[test]
fn monthly_uniform_crosses_year_boundary() {
let plan = Abschlagsplan::monthly_uniform(
"51238696782",
date!(2025 - 07 - 01),
12,
dec!(1200.00),
dec!(3000),
);
assert_eq!(plan.entries.len(), 12);
assert_eq!(plan.entries[0].faellig_am.month(), time::Month::July);
assert_eq!(plan.entries[0].faellig_am.year(), 2025);
assert_eq!(plan.entries[5].faellig_am.month(), time::Month::December);
assert_eq!(plan.entries[6].faellig_am.month(), time::Month::January);
assert_eq!(plan.entries[6].faellig_am.year(), 2026);
}
}