use rust_decimal::{Decimal, dec};
use crate::interval::MeasurementUnit;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GasConversionParams {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub hs_kwh_per_m3: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub zustandszahl: Decimal,
}
impl GasConversionParams {
#[must_use]
pub const fn new(hs_kwh_per_m3: Decimal, zustandszahl: Decimal) -> Self {
Self {
hs_kwh_per_m3,
zustandszahl,
}
}
#[must_use]
pub const fn already_converted(hs_kwh_per_m3: Decimal) -> Self {
Self {
hs_kwh_per_m3,
zustandszahl: Decimal::ONE,
}
}
}
#[must_use]
pub fn gas_m3_to_kwh_hs(
volume_m3: Decimal,
hs_kwh_per_m3: Decimal,
zustandszahl: Decimal,
) -> Decimal {
volume_m3 * hs_kwh_per_m3 * zustandszahl
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "SCREAMING_SNAKE_CASE"))]
pub enum G685FinalRounding {
#[default]
None,
WholeKwh,
TwoDecimals,
}
impl G685FinalRounding {
pub const ALL: [Self; 3] = [Self::None, Self::WholeKwh, Self::TwoDecimals];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "NONE",
Self::WholeKwh => "WHOLE_KWH",
Self::TwoDecimals => "TWO_DECIMALS",
}
}
}
crate::codes::string_codes! {
G685FinalRounding;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct G685Rounding {
pub zustandszahl_dp: u32,
pub brennwert_dp: u32,
pub final_rounding: G685FinalRounding,
}
impl Default for G685Rounding {
fn default() -> Self {
Self {
zustandszahl_dp: 4,
brennwert_dp: 3,
final_rounding: G685FinalRounding::None,
}
}
}
#[must_use]
pub fn gas_m3_to_kwh_hs_rounded(
volume_m3: Decimal,
hs_kwh_per_m3: Decimal,
zustandszahl: Decimal,
rounding: G685Rounding,
) -> Decimal {
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
let hs = hs_kwh_per_m3.round_dp_with_strategy(rounding.brennwert_dp, MidpointAwayFromZero);
let z = zustandszahl.round_dp_with_strategy(rounding.zustandszahl_dp, MidpointAwayFromZero);
let kwh = volume_m3 * hs * z;
match rounding.final_rounding {
G685FinalRounding::None => kwh,
G685FinalRounding::WholeKwh => kwh.round_dp_with_strategy(0, MidpointAwayFromZero),
G685FinalRounding::TwoDecimals => kwh.round_dp_with_strategy(2, MidpointAwayFromZero),
}
}
pub const NORMTEMPERATUR_K: Decimal = dec!(273.15);
pub const NORMDRUCK_MBAR: Decimal = dec!(1013.25);
pub const ABRECHNUNGSTEMPERATUR_C: Decimal = dec!(15);
pub const K_EINS_GRENZE_MBAR: Decimal = dec!(1000);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ZustandszahlParams {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub luftdruck_mbar: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub effektivdruck_mbar: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub abrechnungstemperatur_c: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
pub kompressibilitaetszahl: Decimal,
}
impl ZustandszahlParams {
#[must_use]
pub fn niederdruck(luftdruck_mbar: Decimal, effektivdruck_mbar: Decimal) -> Option<Self> {
(effektivdruck_mbar < K_EINS_GRENZE_MBAR).then_some(Self {
luftdruck_mbar,
effektivdruck_mbar,
abrechnungstemperatur_c: ABRECHNUNGSTEMPERATUR_C,
kompressibilitaetszahl: Decimal::ONE,
})
}
#[must_use]
pub const fn new(
luftdruck_mbar: Decimal,
effektivdruck_mbar: Decimal,
abrechnungstemperatur_c: Decimal,
kompressibilitaetszahl: Decimal,
) -> Self {
Self {
luftdruck_mbar,
effektivdruck_mbar,
abrechnungstemperatur_c,
kompressibilitaetszahl,
}
}
#[must_use]
pub fn absolutdruck_mbar(&self) -> Decimal {
self.luftdruck_mbar + self.effektivdruck_mbar
}
}
#[must_use]
pub fn hoehenzonen_luftdruck_mbar(hoehe_m: Decimal) -> Decimal {
dec!(1016) - dec!(0.12) * hoehe_m
}
#[must_use]
pub fn zustandszahl(params: &ZustandszahlParams) -> Option<Decimal> {
let t_eff = NORMTEMPERATUR_K + params.abrechnungstemperatur_c;
let denominator = t_eff * NORMDRUCK_MBAR * params.kompressibilitaetszahl;
let numerator = NORMTEMPERATUR_K * params.absolutdruck_mbar();
(t_eff > Decimal::ZERO
&& params.kompressibilitaetszahl > Decimal::ZERO
&& numerator > Decimal::ZERO)
.then(|| numerator / denominator)
}
pub fn normalize_to_kwh(
value: Decimal,
unit: &str,
gas: Option<&GasConversionParams>,
duration_secs: Option<i64>,
) -> Result<Decimal, ConversionError> {
let trimmed = unit.trim();
if trimmed.eq_ignore_ascii_case("kw") {
let secs = duration_secs.ok_or(ConversionError::MissingDuration)?;
if secs <= 0 {
return Err(ConversionError::MissingDuration);
}
let hours = Decimal::from(secs) / Decimal::from(3600u32);
return Ok(value * hours);
}
let scale = MeasurementUnit::parse_scaled(trimmed)
.ok_or_else(|| ConversionError::UnknownUnit(trimmed.to_owned()))?;
match scale.unit {
MeasurementUnit::KiloWattHour => Ok(scale.apply(value)),
MeasurementUnit::CubicMetre => {
let params = gas.ok_or(ConversionError::MissingGasParameters)?;
let m3 = scale.apply(value);
Ok(gas_m3_to_kwh_hs(
m3,
params.hs_kwh_per_m3,
params.zustandszahl,
))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConversionError {
#[error("unknown unit {0:?} — see MeasurementUnit::parse_scaled for the accepted set")]
UnknownUnit(String),
#[error("a volume reading needs GasConversionParams (Brennwert and Zustandszahl)")]
MissingGasParameters,
#[error("a power reading needs a positive interval duration to become an energy")]
MissingDuration,
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
#[test]
fn gas_conversion_exact() {
let kwh = gas_m3_to_kwh_hs(dec!(100), dec!(10.55), dec!(1.0));
assert_eq!(kwh, dec!(1055.00));
}
#[test]
fn gas_conversion_with_zustandszahl() {
let kwh = gas_m3_to_kwh_hs(dec!(50), dec!(10.80), dec!(0.9800));
assert_eq!(kwh, dec!(529.2000));
}
#[test]
fn gas_conversion_zero_volume() {
assert_eq!(gas_m3_to_kwh_hs(dec!(0), dec!(10.55), dec!(1.0)), dec!(0));
}
#[test]
fn the_normzustand_has_a_zustandszahl_of_exactly_one() {
let at_norm = ZustandszahlParams::new(NORMDRUCK_MBAR, dec!(0), dec!(0), Decimal::ONE);
assert_eq!(zustandszahl(&at_norm), Some(Decimal::ONE));
}
#[test]
fn the_g685_worked_example_reproduces() {
let luftdruck = hoehenzonen_luftdruck_mbar(dec!(253));
assert_eq!(luftdruck, dec!(985.64));
let params = ZustandszahlParams::niederdruck(luftdruck, dec!(22))
.expect("22 mbar is well below one bar");
assert_eq!(params.absolutdruck_mbar(), dec!(1007.64));
assert_eq!(params.abrechnungstemperatur_c, ABRECHNUNGSTEMPERATUR_C);
assert_eq!(params.kompressibilitaetszahl, Decimal::ONE);
let z = zustandszahl(¶ms).expect("a positive gas state");
assert_eq!(z.round_dp(4), dec!(0.9427));
let kwh = gas_m3_to_kwh_hs_rounded(dec!(1874), dec!(11.316), z, G685Rounding::default());
assert_eq!(kwh.round_dp(2), dec!(19991.07));
}
#[test]
fn the_k_equals_one_shortcut_stops_at_one_bar() {
assert!(ZustandszahlParams::niederdruck(dec!(1013.25), dec!(999.9)).is_some());
assert!(ZustandszahlParams::niederdruck(dec!(1013.25), K_EINS_GRENZE_MBAR).is_none());
assert!(ZustandszahlParams::niederdruck(dec!(1013.25), dec!(4000)).is_none());
}
#[test]
fn an_impossible_gas_state_has_no_zustandszahl() {
let frozen = ZustandszahlParams::new(dec!(1013.25), dec!(0), dec!(-273.15), Decimal::ONE);
assert_eq!(zustandszahl(&frozen), None);
let colder = ZustandszahlParams::new(dec!(1013.25), dec!(0), dec!(-300), Decimal::ONE);
assert_eq!(zustandszahl(&colder), None);
let no_k = ZustandszahlParams::new(dec!(1013.25), dec!(0), dec!(15), Decimal::ZERO);
assert_eq!(zustandszahl(&no_k), None);
let vacuum = ZustandszahlParams::new(dec!(0), dec!(0), dec!(15), Decimal::ONE);
assert_eq!(zustandszahl(&vacuum), None);
}
#[test]
fn a_higher_hoehenzone_has_a_smaller_zustandszahl() {
let z_at = |h| {
zustandszahl(
&ZustandszahlParams::niederdruck(hoehenzonen_luftdruck_mbar(h), dec!(22))
.expect("niederdruck"),
)
.expect("a positive gas state")
};
assert!(z_at(dec!(0)) > z_at(dec!(253)));
assert!(z_at(dec!(253)) > z_at(dec!(1000)));
}
#[test]
fn an_already_converted_volume_carries_no_zustandszahl() {
let p = GasConversionParams::already_converted(dec!(11.2));
assert_eq!(p.zustandszahl, Decimal::ONE);
assert_eq!(p, GasConversionParams::new(dec!(11.2), Decimal::ONE));
let normvolumen = gas_m3_to_kwh_hs(dec!(100), p.hs_kwh_per_m3, p.zustandszahl);
assert_eq!(normvolumen, dec!(1120.0));
let doubled = gas_m3_to_kwh_hs(dec!(100), dec!(11.2), dec!(0.98));
assert!(doubled < normvolumen);
}
}
#[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 *= Decimal::from_str_exact("1.11").unwrap_or(Decimal::ONE);
}
if self.eigenstaendige_gewerbliche_waermelieferung {
q /= Decimal::from_str_exact("1.15").unwrap_or(Decimal::ONE);
}
if self.monovalente_waermepumpe {
q *= Decimal::from_str_exact("0.30").unwrap_or(Decimal::ONE);
}
q
}
}
#[must_use]
pub fn warm_water_heat_kwh(
volume_m3: Decimal,
mean_temp_c: Decimal,
adjustments: WarmWaterAdjustments,
) -> Decimal {
let factor = Decimal::from_str_exact("2.5").unwrap_or(Decimal::from(2u32));
let cold_inlet = Decimal::from(10u32);
adjustments.apply(factor * volume_m3 * (mean_temp_c - cold_inlet))
}
#[must_use]
pub fn warm_water_heat_kwh_unmetered(
flaeche_m2: Decimal,
adjustments: WarmWaterAdjustments,
) -> Decimal {
adjustments.apply(Decimal::from(32u32) * flaeche_m2)
}
#[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
),
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
),
Decimal::ZERO
);
assert!(
warm_water_heat_kwh(
Decimal::from(40u32),
Decimal::from(5u32),
WarmWaterAdjustments::NONE
) < Decimal::ZERO
);
}
#[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), base * d("1.11"));
let wp = WarmWaterAdjustments {
monovalente_waermepumpe: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(warm_water_heat_kwh(v, t, wp), base * d("0.30"));
let gewerblich = WarmWaterAdjustments {
eigenstaendige_gewerbliche_waermelieferung: true,
..WarmWaterAdjustments::NONE
};
assert_eq!(warm_water_heat_kwh(v, t, gewerblich), 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, 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")
);
}
#[test]
fn g685_rounded_matches_the_published_eneregio_example() {
let kwh = gas_m3_to_kwh_hs_rounded(
Decimal::from(895u32),
Decimal::from_str_exact("11.369").unwrap(),
Decimal::from_str_exact("0.9543").unwrap(),
G685Rounding {
final_rounding: G685FinalRounding::WholeKwh,
..G685Rounding::default()
},
);
assert_eq!(kwh, Decimal::from(9710u32));
}
#[test]
fn g685_input_rounding_is_kaufmaennisch() {
let kwh = gas_m3_to_kwh_hs_rounded(
Decimal::ONE,
Decimal::from_str_exact("10.0005").unwrap(), Decimal::from_str_exact("0.95435").unwrap(), G685Rounding::default(),
);
assert_eq!(
kwh,
Decimal::from_str_exact("10.001").unwrap() * Decimal::from_str_exact("0.9544").unwrap()
);
}
}