use rust_decimal::{Decimal, dec};
use crate::series::interval::Unit;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct GasConversionParams {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
hs_kwh_per_m3: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
zustandszahl: Decimal,
}
impl GasConversionParams {
#[must_use]
pub const fn hs_kwh_per_m3(&self) -> Decimal {
self.hs_kwh_per_m3
}
#[must_use]
pub const fn zustandszahl(&self) -> Decimal {
self.zustandszahl
}
#[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,
) -> Option<Decimal> {
volume_m3
.checked_mul(hs_kwh_per_m3)?
.checked_mul(zustandszahl)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum G685FinalRounding {
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::ids::codes::string_codes! {
G685FinalRounding;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct G685Rounding {
zustandszahl_dp: u32,
brennwert_dp: u32,
final_rounding: G685FinalRounding,
}
impl G685Rounding {
pub const PUBLISHED_PRACTICE: Self = Self {
zustandszahl_dp: crate::precision::G685_ZUSTANDSZAHL_DP,
brennwert_dp: crate::precision::G685_BRENNWERT_DP,
final_rounding: G685FinalRounding::None,
};
#[must_use]
pub const fn zustandszahl_dp(mut self, dp: u32) -> Self {
self.zustandszahl_dp = dp;
self
}
#[must_use]
pub const fn brennwert_dp(mut self, dp: u32) -> Self {
self.brennwert_dp = dp;
self
}
#[must_use]
pub const fn final_rounding(mut self, mode: G685FinalRounding) -> Self {
self.final_rounding = mode;
self
}
}
#[must_use]
pub fn gas_m3_to_kwh_hs_rounded(
volume_m3: Decimal,
hs_kwh_per_m3: Decimal,
zustandszahl: Decimal,
rounding: G685Rounding,
) -> Option<Decimal> {
use crate::precision::G685_STRATEGY;
let hs = hs_kwh_per_m3.round_dp_with_strategy(rounding.brennwert_dp, G685_STRATEGY);
let z = zustandszahl.round_dp_with_strategy(rounding.zustandszahl_dp, G685_STRATEGY);
let kwh = gas_m3_to_kwh_hs(volume_m3, hs, z)?;
Some(match rounding.final_rounding {
G685FinalRounding::None => kwh,
G685FinalRounding::WholeKwh => kwh.round_dp_with_strategy(0, G685_STRATEGY),
G685FinalRounding::TwoDecimals => kwh.round_dp_with_strategy(2, G685_STRATEGY),
})
}
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))]
#[non_exhaustive]
pub struct ZustandszahlParams {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
luftdruck_mbar: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
effektivdruck_mbar: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
abrechnungstemperatur_c: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
kompressibilitaetszahl: Decimal,
}
impl ZustandszahlParams {
#[must_use]
pub fn below_one_bar(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 const fn luftdruck_mbar(&self) -> Decimal {
self.luftdruck_mbar
}
#[must_use]
pub const fn effektivdruck_mbar(&self) -> Decimal {
self.effektivdruck_mbar
}
#[must_use]
pub const fn abrechnungstemperatur_c(&self) -> Decimal {
self.abrechnungstemperatur_c
}
#[must_use]
pub const fn kompressibilitaetszahl(&self) -> Decimal {
self.kompressibilitaetszahl
}
#[must_use]
pub fn absolutdruck_mbar(&self) -> Option<Decimal> {
self.luftdruck_mbar.checked_add(self.effektivdruck_mbar)
}
}
#[must_use]
pub fn hoehenzonen_luftdruck_mbar(hoehe_m: Decimal) -> Option<Decimal> {
dec!(1016).checked_sub(dec!(0.12).checked_mul(hoehe_m)?)
}
#[must_use]
pub fn zustandszahl(params: &ZustandszahlParams) -> Option<Decimal> {
let t_eff = NORMTEMPERATUR_K.checked_add(params.abrechnungstemperatur_c)?;
let denominator = t_eff
.checked_mul(NORMDRUCK_MBAR)?
.checked_mul(params.kompressibilitaetszahl)?;
let numerator = NORMTEMPERATUR_K.checked_mul(params.absolutdruck_mbar()?)?;
if t_eff <= Decimal::ZERO
|| params.kompressibilitaetszahl <= Decimal::ZERO
|| numerator <= Decimal::ZERO
{
return None;
}
numerator.checked_div(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);
}
return value
.checked_mul(Decimal::from(secs))
.and_then(|e| e.checked_div(Decimal::from(3600u32)))
.ok_or(ConversionError::Overflow);
}
let scale = Unit::parse_scaled(trimmed)
.ok_or_else(|| ConversionError::UnknownUnit(trimmed.to_owned()))?;
match scale.unit() {
Unit::KiloWattHour => scale.apply(value).ok_or(ConversionError::Overflow),
Unit::CubicMetre => {
let params = gas.ok_or(ConversionError::MissingGasParameters)?;
let m3 = scale.apply(value).ok_or(ConversionError::Overflow)?;
gas_m3_to_kwh_hs(m3, params.hs_kwh_per_m3, params.zustandszahl)
.ok_or(ConversionError::Overflow)
}
_ => Err(ConversionError::UnknownUnit(trimmed.to_owned())),
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ConversionError {
#[error("unknown unit {0:?} — see Unit::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,
#[error("the converted value overflows")]
Overflow,
}
#[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, Some(dec!(1055.00)));
assert_eq!(gas_m3_to_kwh_hs(Decimal::MAX, dec!(10.55), dec!(1.0)), None);
}
#[test]
fn gas_conversion_with_zustandszahl() {
let kwh = gas_m3_to_kwh_hs(dec!(50), dec!(10.80), dec!(0.9800));
assert_eq!(kwh, Some(dec!(529.2000)));
}
#[test]
fn gas_conversion_zero_volume() {
assert_eq!(
gas_m3_to_kwh_hs(dec!(0), dec!(10.55), dec!(1.0)),
Some(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)).unwrap();
assert_eq!(luftdruck, dec!(985.64));
let params = ZustandszahlParams::below_one_bar(luftdruck, dec!(22))
.expect("22 mbar is well below one bar");
assert_eq!(params.absolutdruck_mbar(), Some(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_with_strategy(4, crate::precision::G685_STRATEGY),
dec!(0.9427)
);
let kwh = gas_m3_to_kwh_hs_rounded(
dec!(1874),
dec!(11.316),
z,
G685Rounding::PUBLISHED_PRACTICE,
)
.unwrap();
assert_eq!(
kwh.round_dp_with_strategy(2, crate::precision::G685_STRATEGY),
dec!(19991.07)
);
}
#[test]
fn the_k_equals_one_shortcut_stops_at_one_bar() {
assert!(ZustandszahlParams::below_one_bar(dec!(1013.25), dec!(999.9)).is_some());
assert!(ZustandszahlParams::below_one_bar(dec!(1013.25), K_EINS_GRENZE_MBAR).is_none());
assert!(ZustandszahlParams::below_one_bar(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::below_one_bar(
hoehenzonen_luftdruck_mbar(h).unwrap(),
dec!(22),
)
.expect("below one bar"),
)
.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()).unwrap();
assert_eq!(normvolumen, dec!(1120.0));
let doubled = gas_m3_to_kwh_hs(dec!(100), dec!(11.2), dec!(0.98)).unwrap();
assert!(doubled < normvolumen);
}
}
#[cfg(test)]
mod g685_rounding_tests {
use super::*;
#[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::PUBLISHED_PRACTICE.final_rounding(G685FinalRounding::WholeKwh),
);
assert_eq!(kwh, Some(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::PUBLISHED_PRACTICE,
);
assert_eq!(
kwh,
Some(
Decimal::from_str_exact("10.001").unwrap()
* Decimal::from_str_exact("0.9544").unwrap()
)
);
}
}