use rust_decimal::{Decimal, dec};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub const RATIO_HALF: Decimal = dec!(0.5);
pub const RATIO_COS_PHI_0_9: Decimal = dec!(0.4843);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ReactiveLimit {
ratio: Decimal,
}
impl ReactiveLimit {
#[must_use]
pub fn new(ratio: Decimal) -> Option<Self> {
(ratio >= Decimal::ZERO).then_some(Self { ratio })
}
pub const HALF: Self = Self { ratio: RATIO_HALF };
pub const COS_PHI_0_9: Self = Self {
ratio: RATIO_COS_PHI_0_9,
};
#[must_use]
pub const fn ratio(&self) -> Decimal {
self.ratio
}
}
#[cfg(feature = "serde")]
#[derive(Serialize, Deserialize)]
struct RawLimit {
#[serde(with = "crate::wire::decimal")]
ratio: Decimal,
}
#[cfg(feature = "serde")]
impl Serialize for ReactiveLimit {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
RawLimit { ratio: self.ratio }.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for ReactiveLimit {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = RawLimit::deserialize(deserializer)?;
Self::new(raw.ratio)
.ok_or_else(|| serde::de::Error::custom("a reactive ratio is not negative"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize))]
#[non_exhaustive]
pub struct ReactiveBalance {
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
wirkarbeit_kwh: Decimal,
#[cfg_attr(feature = "serde", serde(with = "crate::wire::decimal"))]
blindarbeit_kvarh: Decimal,
limit: ReactiveLimit,
}
impl ReactiveBalance {
#[must_use]
pub fn new(
wirkarbeit_kwh: Decimal,
blindarbeit_kvarh: Decimal,
limit: ReactiveLimit,
) -> Option<Self> {
if wirkarbeit_kwh < Decimal::ZERO || blindarbeit_kvarh < Decimal::ZERO {
return None;
}
limit.ratio.checked_mul(wirkarbeit_kwh)?;
Some(Self {
wirkarbeit_kwh,
blindarbeit_kvarh,
limit,
})
}
#[must_use]
pub const fn wirkarbeit_kwh(&self) -> Decimal {
self.wirkarbeit_kwh
}
#[must_use]
pub const fn blindarbeit_kvarh(&self) -> Decimal {
self.blindarbeit_kvarh
}
#[must_use]
pub const fn limit(&self) -> ReactiveLimit {
self.limit
}
#[must_use]
pub fn freigrenze_kvarh(&self) -> Decimal {
self.limit.ratio * self.wirkarbeit_kwh
}
#[must_use]
pub fn blindmehrarbeit_kvarh(&self) -> Decimal {
(self.blindarbeit_kvarh - self.freigrenze_kvarh()).max(Decimal::ZERO)
}
#[must_use]
pub fn is_chargeable(&self) -> bool {
self.blindmehrarbeit_kvarh() > Decimal::ZERO
}
#[must_use]
pub fn headroom_kvarh(&self) -> Decimal {
(self.freigrenze_kvarh() - self.blindarbeit_kvarh).max(Decimal::ZERO)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn balance(w: Decimal, b: Decimal, limit: ReactiveLimit) -> ReactiveBalance {
ReactiveBalance::new(w, b, limit).unwrap()
}
#[test]
fn the_excess_is_what_the_freigrenze_does_not_cover() {
let b = balance(dec!(1000), dec!(600), ReactiveLimit::HALF);
assert_eq!(b.freigrenze_kvarh(), dec!(500.0));
assert_eq!(b.blindmehrarbeit_kvarh(), dec!(100.0));
assert!(b.is_chargeable());
assert_eq!(b.headroom_kvarh(), Decimal::ZERO);
}
#[test]
fn a_compensated_load_pays_nothing_and_keeps_headroom() {
let b = balance(dec!(1000), dec!(200), ReactiveLimit::HALF);
assert_eq!(b.blindmehrarbeit_kvarh(), Decimal::ZERO);
assert!(!b.is_chargeable());
assert_eq!(b.headroom_kvarh(), dec!(300.0));
}
#[test]
fn cos_phi_zero_nine_is_stricter_than_the_half_rule() {
let half = balance(dec!(1000), dec!(600), ReactiveLimit::HALF);
let strict = balance(dec!(1000), dec!(600), ReactiveLimit::COS_PHI_0_9);
assert!(strict.blindmehrarbeit_kvarh() > half.blindmehrarbeit_kvarh());
assert_eq!(strict.freigrenze_kvarh(), dec!(484.3000));
}
#[test]
fn derived_figures_cannot_contradict_the_inputs() {
let b = balance(
dec!(1234.567),
dec!(1000.001),
ReactiveLimit::new(dec!(0.3333)).unwrap(),
);
assert_eq!(b.freigrenze_kvarh(), dec!(1234.567) * dec!(0.3333));
assert_eq!(
b.blindmehrarbeit_kvarh() + b.freigrenze_kvarh(),
b.blindarbeit_kvarh()
);
assert_eq!(b.limit().ratio(), dec!(0.3333));
}
#[test]
fn negative_inputs_are_refused() {
assert_eq!(ReactiveLimit::new(dec!(-0.5)), None);
assert_eq!(
ReactiveBalance::new(dec!(-1), dec!(1), ReactiveLimit::HALF),
None
);
assert_eq!(
ReactiveBalance::new(dec!(1), dec!(-1), ReactiveLimit::HALF),
None
);
assert_eq!(
ReactiveBalance::new(Decimal::MAX, dec!(1), ReactiveLimit::new(dec!(2)).unwrap()),
None
);
}
#[test]
fn the_published_conventions_are_the_stated_values() {
assert_eq!(ReactiveLimit::COS_PHI_0_9.ratio(), dec!(0.4843));
assert_eq!(ReactiveLimit::HALF.ratio(), dec!(0.5));
}
}