use rust_decimal::Decimal;
use crate::interval::MeasurementUnit;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GasConversionParams {
pub hs_kwh_per_m3: 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 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 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()
);
}
}