use rust_decimal::{Decimal, dec};
use time::Month;
use crate::series::Series;
use crate::series::interval::Unit;
use crate::time::calendar::Period;
const WARMWASSER_FAKTOR: Decimal = dec!(2.5);
const KALTWASSER_TEMPERATUR_C: Decimal = dec!(10);
const WARMWASSER_FLAECHENFAKTOR: Decimal = dec!(32);
const BRENNWERT_ERDGAS_FAKTOR: Decimal = dec!(1.11);
const GEWERBLICHE_WAERMELIEFERUNG_DIVISOR: Decimal = dec!(1.15);
const MONOVALENTE_WAERMEPUMPE_FAKTOR: Decimal = dec!(0.30);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WarmWaterAdjustments {
pub brennwert_erdgas: bool,
pub eigenstaendige_gewerbliche_waermelieferung: bool,
pub monovalente_waermepumpe: bool,
}
impl WarmWaterAdjustments {
pub const NONE: Self = Self {
brennwert_erdgas: false,
eigenstaendige_gewerbliche_waermelieferung: false,
monovalente_waermepumpe: false,
};
fn apply(self, base: Decimal) -> Decimal {
let mut q = base;
if self.brennwert_erdgas {
q *= BRENNWERT_ERDGAS_FAKTOR;
}
if self.eigenstaendige_gewerbliche_waermelieferung {
q /= GEWERBLICHE_WAERMELIEFERUNG_DIVISOR;
}
if self.monovalente_waermepumpe {
q *= MONOVALENTE_WAERMEPUMPE_FAKTOR;
}
q
}
}
#[must_use]
pub fn warm_water_heat_kwh(
volume_m3: Decimal,
mean_temp_c: Decimal,
adjustments: WarmWaterAdjustments,
) -> Option<Decimal> {
(mean_temp_c >= KALTWASSER_TEMPERATUR_C).then(|| {
adjustments.apply(WARMWASSER_FAKTOR * volume_m3 * (mean_temp_c - KALTWASSER_TEMPERATUR_C))
})
}
#[must_use]
pub fn warm_water_heat_kwh_unmetered(
flaeche_m2: Decimal,
adjustments: WarmWaterAdjustments,
) -> Decimal {
adjustments.apply(WARMWASSER_FLAECHENFAKTOR * flaeche_m2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Brennstoff {
LeichtesHeizoel,
SchweresHeizoel,
ErdgasH,
ErdgasL,
Fluessiggas,
Koks,
Braunkohle,
Steinkohle,
Brennholz,
Holzpellets,
Holzhackschnitzel,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Brennstoffeinheit {
Liter,
Kubikmeter,
Kilogramm,
}
impl Brennstoff {
pub const ALL: [Self; 11] = [
Self::LeichtesHeizoel,
Self::SchweresHeizoel,
Self::ErdgasH,
Self::ErdgasL,
Self::Fluessiggas,
Self::Koks,
Self::Braunkohle,
Self::Steinkohle,
Self::Brennholz,
Self::Holzpellets,
Self::Holzhackschnitzel,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LeichtesHeizoel => "LEICHTES_HEIZOEL",
Self::SchweresHeizoel => "SCHWERES_HEIZOEL",
Self::ErdgasH => "ERDGAS_H",
Self::ErdgasL => "ERDGAS_L",
Self::Fluessiggas => "FLUESSIGGAS",
Self::Koks => "KOKS",
Self::Braunkohle => "BRAUNKOHLE",
Self::Steinkohle => "STEINKOHLE",
Self::Brennholz => "BRENNHOLZ",
Self::Holzpellets => "HOLZPELLETS",
Self::Holzhackschnitzel => "HOLZHACKSCHNITZEL",
}
}
#[must_use]
pub const fn heizwert(self) -> Decimal {
match self {
Self::LeichtesHeizoel | Self::ErdgasH => dec!(10),
Self::SchweresHeizoel => dec!(10.9),
Self::ErdgasL => dec!(9),
Self::Fluessiggas => dec!(13),
Self::Koks | Self::Steinkohle => dec!(8),
Self::Braunkohle => dec!(5.5),
Self::Brennholz => dec!(4.1),
Self::Holzpellets => dec!(5),
Self::Holzhackschnitzel => dec!(4),
}
}
#[must_use]
pub const fn einheit(self) -> Brennstoffeinheit {
match self {
Self::LeichtesHeizoel | Self::SchweresHeizoel => Brennstoffeinheit::Liter,
Self::ErdgasH | Self::ErdgasL => Brennstoffeinheit::Kubikmeter,
_ => Brennstoffeinheit::Kilogramm,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Heizwert {
Abrechnungsunterlagen(Decimal),
Hilfsweise(Brennstoff),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Brennstoffmenge {
pub menge: Decimal,
pub heizwert_kwh: Decimal,
pub quelle: Heizwert,
}
#[must_use]
pub fn brennstoffverbrauch(q_kwh: Decimal, heizwert: Heizwert) -> Option<Brennstoffmenge> {
let hi = match heizwert {
Heizwert::Abrechnungsunterlagen(v) => v,
Heizwert::Hilfsweise(f) => f.heizwert(),
};
if hi <= Decimal::ZERO || q_kwh < Decimal::ZERO {
return None;
}
Some(Brennstoffmenge {
menge: q_kwh.checked_div(hi)?,
heizwert_kwh: hi,
quelle: heizwert,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Erfassung {
Waermemenge,
Heizkostenverteiler {
kwh_je_einheit: Decimal,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HeizkostenError {
#[error("the interval starting {at} runs across a month boundary")]
Straddles {
at: time::OffsetDateTime,
},
#[error("a heat-meter series must be in kWh")]
Unit,
#[error("the kWh per Heizkostenverteiler unit must be positive")]
Factor,
#[error("the month is outside the supported calendar")]
Calendar,
#[error("arithmetic overflow")]
Overflow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Monatsverbrauch {
pub monat: Period,
pub kwh: Decimal,
pub vollstaendig: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Verbrauchsinformation {
pub monat: Option<Monatsverbrauch>,
pub vormonat: Option<Monatsverbrauch>,
pub vorjahresmonat: Option<Monatsverbrauch>,
}
pub fn verbrauchsinformation(
series: &Series,
erfassung: Erfassung,
year: i32,
month: Month,
) -> Result<Verbrauchsinformation, HeizkostenError> {
let factor = match erfassung {
Erfassung::Waermemenge => {
if series.unit().is_some_and(|u| u != Unit::KiloWattHour) {
return Err(HeizkostenError::Unit);
}
Decimal::ONE
}
Erfassung::Heizkostenverteiler { kwh_je_einheit } => {
if kwh_je_einheit <= Decimal::ZERO {
return Err(HeizkostenError::Factor);
}
kwh_je_einheit
}
};
let boundary = series.boundary();
let previous = if month == Month::January {
(
year.checked_sub(1).ok_or(HeizkostenError::Calendar)?,
Month::December,
)
} else {
(year, month.previous())
};
let figure = |y: i32, m: Month| -> Result<Option<Monatsverbrauch>, HeizkostenError> {
let Some(period) = boundary.month(y, m) else {
return Ok(None);
};
let mut kwh = Decimal::ZERO;
let mut covered = time::Duration::ZERO;
let mut any = false;
for iv in series.iter() {
if iv.to() <= period.start() || iv.from() >= period.end() {
continue;
}
if iv.from() < period.start() || iv.to() > period.end() {
return Err(HeizkostenError::Straddles { at: iv.from() });
}
if !iv.quality().is_billable() {
continue;
}
any = true;
kwh = kwh
.checked_add(iv.value())
.ok_or(HeizkostenError::Overflow)?;
covered += iv.duration();
}
Ok(any.then_some(Monatsverbrauch {
monat: period,
kwh: kwh.checked_mul(factor).ok_or(HeizkostenError::Overflow)?,
vollstaendig: covered == period.duration(),
}))
};
if boundary.month(year, month).is_none() {
return Err(HeizkostenError::Calendar);
}
Ok(Verbrauchsinformation {
monat: figure(year, month)?,
vormonat: figure(previous.0, previous.1)?,
vorjahresmonat: figure(year.checked_sub(1).ok_or(HeizkostenError::Calendar)?, month)?,
})
}
pub const SCHAETZGRENZE_PROZENT: Decimal = dec!(25);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Schaetzgrundlage {
VergleichbareZeitraeume,
VergleichbareRaeume,
Durchschnittsverbrauch,
}
impl Schaetzgrundlage {
pub const ALL: [Self; 3] = [
Self::VergleichbareZeitraeume,
Self::VergleichbareRaeume,
Self::Durchschnittsverbrauch,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::VergleichbareZeitraeume => "VERGLEICHBARE_ZEITRAEUME",
Self::VergleichbareRaeume => "VERGLEICHBARE_RAEUME",
Self::Durchschnittsverbrauch => "DURCHSCHNITTSVERBRAUCH",
}
}
}
crate::ids::codes::string_codes! {
Brennstoff;
Schaetzgrundlage;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct Verbrauchsschaetzung {
pub grundlage: Schaetzgrundlage,
pub verbrauch: Decimal,
pub anteil_prozent: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SchaetzungError {
#[error("the affected share exceeds 25 % (§ 9a Abs. 2 HeizkostenV)")]
UeberSchaetzgrenze,
#[error("the affected and total area or volume are inconsistent")]
Flaeche,
}
pub fn verbrauchsschaetzung(
grundlage: Schaetzgrundlage,
verbrauch: Decimal,
betroffen: Decimal,
gesamt: Decimal,
) -> Result<Verbrauchsschaetzung, SchaetzungError> {
if gesamt <= Decimal::ZERO || betroffen < Decimal::ZERO || betroffen > gesamt {
return Err(SchaetzungError::Flaeche);
}
let hundred = Decimal::ONE_HUNDRED;
let lhs = betroffen
.checked_mul(hundred)
.ok_or(SchaetzungError::Flaeche)?;
let rhs = gesamt
.checked_mul(SCHAETZGRENZE_PROZENT)
.ok_or(SchaetzungError::Flaeche)?;
if lhs > rhs {
return Err(SchaetzungError::UeberSchaetzgrenze);
}
Ok(Verbrauchsschaetzung {
grundlage,
verbrauch,
anteil_prozent: lhs.checked_div(gesamt).ok_or(SchaetzungError::Flaeche)?,
})
}
#[cfg(test)]
mod quantity_tests {
use super::*;
use crate::series::interval::{MeterInterval, QualityFlag};
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
use time::Date;
#[test]
fn the_heizwert_table_is_the_statutes() {
let table: Vec<(Brennstoff, Decimal)> =
Brennstoff::ALL.iter().map(|f| (*f, f.heizwert())).collect();
assert_eq!(
table,
[
(Brennstoff::LeichtesHeizoel, dec!(10)),
(Brennstoff::SchweresHeizoel, dec!(10.9)),
(Brennstoff::ErdgasH, dec!(10)),
(Brennstoff::ErdgasL, dec!(9)),
(Brennstoff::Fluessiggas, dec!(13)),
(Brennstoff::Koks, dec!(8)),
(Brennstoff::Braunkohle, dec!(5.5)),
(Brennstoff::Steinkohle, dec!(8)),
(Brennstoff::Brennholz, dec!(4.1)),
(Brennstoff::Holzpellets, dec!(5)),
(Brennstoff::Holzhackschnitzel, dec!(4)),
]
);
assert_eq!(
Brennstoff::SchweresHeizoel.einheit(),
Brennstoffeinheit::Liter
);
assert_eq!(Brennstoff::ErdgasH.einheit(), Brennstoffeinheit::Kubikmeter);
assert_eq!(
Brennstoff::Fluessiggas.einheit(),
Brennstoffeinheit::Kilogramm
);
}
#[test]
fn fuel_quantity_reports_its_heizwert() {
let b = brennstoffverbrauch(
dec!(5000),
Heizwert::Hilfsweise(Brennstoff::LeichtesHeizoel),
)
.unwrap();
assert_eq!(b.menge, dec!(500));
let b =
brennstoffverbrauch(dec!(5000), Heizwert::Abrechnungsunterlagen(dec!(10.4))).unwrap();
assert_eq!(b.heizwert_kwh, dec!(10.4));
assert_eq!(b.quelle, Heizwert::Abrechnungsunterlagen(dec!(10.4)));
assert!(brennstoffverbrauch(dec!(1), Heizwert::Abrechnungsunterlagen(dec!(0))).is_none());
assert!(brennstoffverbrauch(dec!(-1), Heizwert::Hilfsweise(Brennstoff::Koks)).is_none());
}
#[test]
fn the_25_percent_rule() {
assert_eq!(SCHAETZGRENZE_PROZENT, dec!(25));
let ok = verbrauchsschaetzung(
Schaetzgrundlage::VergleichbareRaeume,
dec!(1200),
dec!(25),
dec!(100),
)
.unwrap();
assert_eq!(ok.anteil_prozent, dec!(25));
assert_eq!(ok.grundlage, Schaetzgrundlage::VergleichbareRaeume);
assert_eq!(
verbrauchsschaetzung(
Schaetzgrundlage::VergleichbareRaeume,
dec!(1),
dec!(25.01),
dec!(100)
),
Err(SchaetzungError::UeberSchaetzgrenze)
);
assert_eq!(
verbrauchsschaetzung(
Schaetzgrundlage::Durchschnittsverbrauch,
dec!(1),
dec!(1),
dec!(0)
),
Err(SchaetzungError::Flaeche)
);
}
fn daily(first: Date, values: &[Decimal]) -> Series {
let ivs = values
.iter()
.enumerate()
.map(|(i, &v)| {
let day = DayBoundary::Strom
.day(first + time::Duration::days(i as i64))
.unwrap();
MeterInterval::new(day.start(), day.end(), v, QualityFlag::Measured).unwrap()
})
.collect();
Series::new(Resolution::Day, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn monthly_information_on_the_berlin_calendar() {
let feb_mar = daily(time::macros::date!(2026 - 02 - 01), &vec![dec!(2); 28 + 31]);
let info =
verbrauchsinformation(&feb_mar, Erfassung::Waermemenge, 2026, Month::March).unwrap();
let m = info.monat.unwrap();
assert_eq!((m.kwh, m.vollstaendig), (dec!(62), true));
assert_eq!(info.vormonat.unwrap().kwh, dec!(56));
assert_eq!(info.vorjahresmonat, None);
let partial = daily(time::macros::date!(2026 - 03 - 17), &vec![dec!(1); 15]);
let m = verbrauchsinformation(
&partial,
Erfassung::Heizkostenverteiler {
kwh_je_einheit: dec!(3),
},
2026,
Month::March,
)
.unwrap()
.monat
.unwrap();
assert_eq!((m.kwh, m.vollstaendig), (dec!(45), false));
assert_eq!(
verbrauchsinformation(
&partial,
Erfassung::Heizkostenverteiler {
kwh_je_einheit: dec!(0)
},
2026,
Month::March
),
Err(HeizkostenError::Factor)
);
}
#[test]
fn an_interval_across_the_month_is_refused() {
let year = DayBoundary::Strom.year(2026).unwrap();
let iv = MeterInterval::new(year.start(), year.end(), dec!(1200), QualityFlag::Measured)
.unwrap();
let series = Series::new(Resolution::Year, DayBoundary::Strom, vec![iv]).unwrap();
assert_eq!(
verbrauchsinformation(&series, Erfassung::Waermemenge, 2026, Month::March),
Err(HeizkostenError::Straddles { at: year.start() })
);
}
#[test]
fn a_heat_meter_series_not_in_kwh_is_refused() {
let m3: crate::ObisCode = "7-0:3.0.0".parse().unwrap();
let ivs = (0..31)
.map(|i| {
let day = DayBoundary::Strom
.day(time::macros::date!(2026 - 03 - 01) + time::Duration::days(i))
.unwrap();
MeterInterval::new(day.start(), day.end(), dec!(1), QualityFlag::Measured)
.unwrap()
.with_obis(m3)
})
.collect();
let series = Series::new(Resolution::Day, DayBoundary::Strom, ivs).unwrap();
assert_ne!(series.unit(), Some(Unit::KiloWattHour));
assert_eq!(
verbrauchsinformation(&series, Erfassung::Waermemenge, 2026, Month::March),
Err(HeizkostenError::Unit)
);
}
}
#[cfg(test)]
mod warm_water_tests {
use super::*;
fn d(s: &str) -> Decimal {
Decimal::from_str_exact(s).unwrap()
}
#[test]
fn metered_warm_water_follows_the_statutory_formula() {
assert_eq!(
warm_water_heat_kwh(
Decimal::from(40u32),
Decimal::from(60u32),
WarmWaterAdjustments::NONE
),
Some(Decimal::from(5000u32))
);
}
#[test]
fn at_and_below_cold_inlet_temperature() {
assert_eq!(
warm_water_heat_kwh(
Decimal::from(40u32),
Decimal::from(10u32),
WarmWaterAdjustments::NONE
),
Some(Decimal::ZERO)
);
assert_eq!(
warm_water_heat_kwh(
Decimal::from(40u32),
Decimal::from(5u32),
WarmWaterAdjustments::NONE
),
None
);
}
#[test]
fn adjustments_match_the_statutory_factors() {
let v = Decimal::from(40u32);
let t = Decimal::from(60u32);
let base = Decimal::from(5000u32);
let brennwert = WarmWaterAdjustments {
brennwert_erdgas: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(warm_water_heat_kwh(v, t, brennwert), Some(base * d("1.11")));
let wp = WarmWaterAdjustments {
monovalente_waermepumpe: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(warm_water_heat_kwh(v, t, wp), Some(base * d("0.30")));
let gewerblich = WarmWaterAdjustments {
eigenstaendige_gewerbliche_waermelieferung: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(
warm_water_heat_kwh(v, t, gewerblich),
Some(base / d("1.15"))
);
}
#[test]
fn adjustments_compose() {
let both = WarmWaterAdjustments {
eigenstaendige_gewerbliche_waermelieferung: true,
monovalente_waermepumpe: true,
..WarmWaterAdjustments::NONE
};
let q = warm_water_heat_kwh(Decimal::from(40u32), Decimal::from(60u32), both);
assert_eq!(q, Some(Decimal::from(5000u32) / d("1.15") * d("0.30")));
}
#[test]
fn unmetered_fallback_uses_floor_area_and_takes_adjustments() {
assert_eq!(
warm_water_heat_kwh_unmetered(Decimal::from(75u32), WarmWaterAdjustments::NONE),
Decimal::from(2400u32)
);
let brennwert = WarmWaterAdjustments {
brennwert_erdgas: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(
warm_water_heat_kwh_unmetered(Decimal::from(75u32), brennwert),
Decimal::from(2400u32) * d("1.11")
);
}
}