use rust_decimal::Decimal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
pub enum Sparte {
#[default]
Strom,
Gas,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum GemeindeGroesse {
Bis25k,
Bis100k,
Bis500k,
Ueber500k,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum KaKundengruppe {
Tarifkunde {
gemeinde: GemeindeGroesse,
nur_kochen_warmwasser: bool,
},
Schwachlast,
Sondervertragskunde,
Exempt,
}
impl KaKundengruppe {
#[must_use]
pub fn hoechstsatz_ct_per_kwh(self, sparte: Sparte) -> Option<Decimal> {
let pick = |a: &str| Decimal::from_str_exact(a).ok();
match (self, sparte) {
(Self::Exempt, _) => None,
(Self::Schwachlast, Sparte::Strom) => pick("0.61"),
(Self::Schwachlast, Sparte::Gas) => None,
(Self::Sondervertragskunde, Sparte::Strom) => pick("0.11"),
(Self::Sondervertragskunde, Sparte::Gas) => pick("0.03"),
(Self::Tarifkunde { gemeinde, .. }, Sparte::Strom) => pick(match gemeinde {
GemeindeGroesse::Bis25k => "1.32",
GemeindeGroesse::Bis100k => "1.59",
GemeindeGroesse::Bis500k => "1.99",
GemeindeGroesse::Ueber500k => "2.39",
}),
(
Self::Tarifkunde {
gemeinde,
nur_kochen_warmwasser: true,
},
Sparte::Gas,
) => pick(match gemeinde {
GemeindeGroesse::Bis25k => "0.51",
GemeindeGroesse::Bis100k => "0.61",
GemeindeGroesse::Bis500k => "0.77",
GemeindeGroesse::Ueber500k => "0.93",
}),
(
Self::Tarifkunde {
gemeinde,
nur_kochen_warmwasser: false,
},
Sparte::Gas,
) => pick(match gemeinde {
GemeindeGroesse::Bis25k => "0.22",
GemeindeGroesse::Bis100k => "0.27",
GemeindeGroesse::Bis500k => "0.33",
GemeindeGroesse::Ueber500k => "0.40",
}),
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Tarifkunde { .. } => "KAV §2 Abs. 2 Tarifkunde",
Self::Schwachlast => "KAV §2 Abs. 2 Schwachlast",
Self::Sondervertragskunde => "KAV §2 Abs. 3 Sondervertragskunde",
Self::Exempt => "KAV §2 Abs. 7 — freigestellt",
}
}
#[must_use]
pub const fn kav_paragraph(self) -> &'static str {
match self {
Self::Tarifkunde { .. } | Self::Schwachlast => "§2 Abs. 2",
Self::Sondervertragskunde => "§2 Abs. 3",
Self::Exempt => "§2 Abs. 7",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum QuantityUnit {
Kwh,
Kw,
Kvarh,
Kvar,
Monat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum Sect14aModule {
Modul1,
Modul2,
Modul3,
}
impl Sect14aModule {
#[must_use]
pub fn bnentza_reference(self) -> &'static str {
"BK6-22-300"
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Modul1 => "§14a EnWG Modul 1 (pauschale Reduzierung)",
Self::Modul2 => "§14a EnWG Modul 2 (HT/NT variable)",
Self::Modul3 => "§14a EnWG Modul 3 (Spotpreis)",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum SettlementType {
NneStrom,
NneGas,
NneSelbstausstellt,
MmmStrom,
MmmGas,
MsbRechnung,
GasAwhSperrung,
RedispatchKostenblatt,
DezentraleEinspeisung,
}
impl SettlementType {
#[must_use]
pub fn default_pid(self) -> u32 {
match self {
Self::NneStrom => 31001,
Self::NneGas => 31005,
Self::NneSelbstausstellt => 31006,
Self::MmmStrom => 31002,
Self::MmmGas => 31002,
Self::MsbRechnung => 31009,
Self::GasAwhSperrung => 31011,
Self::RedispatchKostenblatt => 0, Self::DezentraleEinspeisung => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum SettlementStatus {
Initial,
Correction,
Reversal,
Final,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum LegalReference {
StromNev {
paragraph: &'static str,
},
GasNev {
paragraph: &'static str,
},
Kav {
paragraph: &'static str,
},
Kwkg {
paragraph: &'static str,
},
EnFG {
paragraph: &'static str,
},
Sect14aEnwg {
module: Sect14aModule,
},
MessZv {
paragraph: &'static str,
},
MsbG {
paragraph: &'static str,
},
BnetzaDecision {
reference: &'static str,
},
BdewAhb {
reference: &'static str,
},
StromNzv {
paragraph: &'static str,
},
GasNzv {
paragraph: &'static str,
},
Enwg {
paragraph: &'static str,
},
ARegV {
paragraph: &'static str,
},
}
impl LegalReference {
#[must_use]
pub fn citation(&self) -> String {
match self {
Self::StromNev { paragraph } => format!("StromNEV {paragraph}"),
Self::GasNev { paragraph } => format!("GasNEV {paragraph}"),
Self::Kav { paragraph } => format!("KAV {paragraph}"),
Self::Kwkg { paragraph } => format!("KWKG {paragraph}"),
Self::EnFG { paragraph } => format!("EnFG {paragraph}"),
Self::Sect14aEnwg { module } => format!("§14a EnWG {}", module.label()),
Self::MessZv { paragraph } => format!("MessZV {paragraph}"),
Self::MsbG { paragraph } => format!("MsbG {paragraph}"),
Self::BnetzaDecision { reference } => format!("BNetzA {reference}"),
Self::BdewAhb { reference } => format!("BDEW {reference}"),
Self::StromNzv { paragraph } => {
format!("StromNZV {paragraph} (außer Kraft seit 01.01.2026)")
}
Self::GasNzv { paragraph } => {
format!("GasNZV {paragraph} (außer Kraft seit 01.01.2026)")
}
Self::Enwg { paragraph } => format!("EnWG {paragraph}"),
Self::ARegV { paragraph } => format!("ARegV {paragraph}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum TariffSource {
PublishedTariffSheet {
sheet_id: String,
},
HistoricalTariff {
valid_from: time::Date,
},
RegulatoryTariff {
decision_ref: &'static str,
},
ContractTariff {
contract_ref: String,
},
ManualOverride {
reason: String,
},
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CalculationTrace {
pub explanation: String,
pub input_quantity: Decimal,
pub input_unit_price_eur: Decimal,
pub gross_eur: Decimal,
pub legal_refs: Vec<LegalReference>,
pub tariff_source: Option<TariffSource>,
pub regulatory_reduction_factor: Option<Decimal>,
pub rounding_note: Option<&'static str>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SettlementWarning {
pub severity: WarningSeverity,
pub code: &'static str,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub enum WarningSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum BillingPositionKind {
NneArbeit,
NneArbeitHt,
NneArbeitNt,
NneArbeitModul1,
NneArbeitModul3,
NneLeistung,
NneGasGrundpreis,
Konzessionsabgabe,
Mehrmenge,
Mindermenge,
MsbGrundgebuehr,
Messdienstleistung,
GasAwhSperrung,
GasAwhEntsprrung,
GasAwhSonstige,
Blindmehrarbeit,
Sect19StromNevUmlage,
OffshoreNetzumlage,
KwkgUmlage,
DezentraleEinspeisung,
Sect19IndividuellesEntgelt,
GasKapazitaetsentgelt,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SpotPriceFormula {
pub reference: PriceReference,
pub unit: QuantityUnit,
pub method: TariffCalculationMethod,
pub steps: Vec<PriceStep>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum PriceReference {
Energiemenge,
Leistung,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum TariffCalculationMethod {
Festpreis,
Spotpreis,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct PriceStep {
pub from: Decimal,
pub to: Option<Decimal>,
pub unit_price_eur: Decimal,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SettlementPosition {
pub text: String,
pub kind: BillingPositionKind,
pub quantity: Decimal,
pub unit: QuantityUnit,
pub unit_price_eur: Decimal,
pub net_eur: Decimal,
pub spot_price_formula: Option<SpotPriceFormula>,
pub trace: CalculationTrace,
}
impl BillingPositionKind {
#[must_use]
pub fn artikelnummer(self, settlement_type: SettlementType) -> Option<&'static str> {
use BillingPositionKind as K;
use SettlementType as ST;
match (self, settlement_type) {
(
K::NneArbeit
| K::NneArbeitHt
| K::NneArbeitNt
| K::NneArbeitModul1
| K::NneArbeitModul3,
ST::NneGas,
) => Some("WIRKARBEIT"),
(K::NneLeistung, ST::NneGas) => Some("LEISTUNG"),
(K::NneGasGrundpreis, _) => Some("GRUNDPREIS"),
(
K::NneArbeit
| K::NneArbeitHt
| K::NneArbeitNt
| K::NneArbeitModul1
| K::NneArbeitModul3
| K::NneLeistung,
_,
) => None,
(K::Konzessionsabgabe, _) => Some("KONZESSIONSABGABE"),
(K::Mehrmenge, _) => Some("MEHRMENGE"),
(K::Mindermenge, _) => Some("MINDERMENGE"),
(K::MsbGrundgebuehr, _) => Some("ENTGELT_EINBAU_BETRIEB_WARTUNG_MESSTECHNIK"),
(K::Messdienstleistung, _) => Some("ENTGELT_MESSUNG_ABLESUNG"),
(K::GasAwhSperrung | K::GasAwhEntsprrung | K::GasAwhSonstige, _) => None,
(K::Blindmehrarbeit, _) => Some("BLINDMEHRARBEIT"),
(K::Sect19StromNevUmlage, _) => Some("PARAGRAF_19_STROM_NEV_UMLAGE"),
(K::DezentraleEinspeisung, _) => None,
(K::Sect19IndividuellesEntgelt, _) => None,
(K::GasKapazitaetsentgelt, ST::NneGas) => Some("LEISTUNG"),
(K::GasKapazitaetsentgelt, _) => None,
(K::OffshoreNetzumlage, _) => Some("OFFSHORE_HAFTUNGSUMLAGE"),
(K::KwkgUmlage, _) => Some("ABGABE_KWKG"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Reduktionsfaktor(Decimal);
impl Reduktionsfaktor {
pub const REGELFALL: Self = Self(rust_decimal::dec!(0.85));
pub fn new(factor: Decimal) -> Result<Self, crate::error::BillingError> {
if factor <= Decimal::ZERO || factor > Decimal::ONE {
return Err(crate::error::BillingError::InvalidInput {
reason: format!("§14a Modul 1 reduction factor must be in (0, 1], got {factor}"),
});
}
Ok(Self(factor))
}
#[must_use]
pub const fn get(self) -> Decimal {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct MengePreis {
pub menge_kwh: Decimal,
pub preis_ct_per_kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub enum ArbeitspreisModell {
Einheitlich(MengePreis),
Modul1Pauschal {
basis: MengePreis,
reduktion: Reduktionsfaktor,
},
Modul2ZeitVariabel {
ht: MengePreis,
nt: MengePreis,
},
Modul3Spotpreis {
intervalle: Vec<Sect14aModul3Interval>,
},
}
impl ArbeitspreisModell {
#[must_use]
pub fn menge_kwh(&self) -> Decimal {
match self {
Self::Einheitlich(mp) | Self::Modul1Pauschal { basis: mp, .. } => mp.menge_kwh,
Self::Modul2ZeitVariabel { ht, nt } => ht.menge_kwh + nt.menge_kwh,
Self::Modul3Spotpreis { intervalle } => intervalle.iter().map(|i| i.menge_kwh).sum(),
}
}
#[must_use]
pub const fn sect14a_modul(&self) -> Option<Sect14aModule> {
match self {
Self::Einheitlich(_) => None,
Self::Modul1Pauschal { .. } => Some(Sect14aModule::Modul1),
Self::Modul2ZeitVariabel { .. } => Some(Sect14aModule::Modul2),
Self::Modul3Spotpreis { .. } => Some(Sect14aModule::Modul3),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Leistungspreis {
pub spitzenleistung_kw: Decimal,
pub preis_eur_per_kw: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Grundpreis {
pub eur_per_month: Decimal,
pub months: Decimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct Konzessionsabgabe {
pub satz_ct_per_kwh: Decimal,
pub klasse: KaKundengruppe,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct SettlementPeriod {
from: time::Date,
to: time::Date,
}
impl SettlementPeriod {
pub fn new(from: time::Date, to: time::Date) -> Result<Self, crate::error::BillingError> {
if from > to {
return Err(crate::error::BillingError::InvalidInput {
reason: format!("period start {from} is after its end {to}"),
});
}
Ok(Self { from, to })
}
#[must_use]
pub const fn from(&self) -> time::Date {
self.from
}
#[must_use]
pub const fn to(&self) -> time::Date {
self.to
}
#[must_use]
pub fn days(&self) -> i64 {
(self.to - self.from).whole_days() + 1
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SettlementResult {
pub settlement_type: SettlementType,
pub status: SettlementStatus,
pub period: SettlementPeriod,
pub regime: crate::regulatory::RegulatoryRegime,
pub sparte: Sparte,
pub malo_id: String,
pub nb_mp_id: String,
pub counterparty_mp_id: String,
pub positions: Vec<SettlementPosition>,
pub total_eur: Decimal,
pub warnings: Vec<SettlementWarning>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct InvoiceDocument {
pub settlement: SettlementResult,
pub pid: u32,
pub rechnungsnummer: String,
pub correction_of: Option<String>,
pub invoice_date: time::Date,
pub due_date: time::Date,
}
impl InvoiceDocument {
pub fn numbered_positions(&self) -> impl Iterator<Item = (u32, &SettlementPosition)> {
self.settlement
.positions
.iter()
.enumerate()
.map(|(i, p)| (u32::try_from(i + 1).unwrap_or(u32::MAX), p))
}
}
impl SettlementResult {
#[must_use]
pub fn positions_count(&self) -> usize {
self.positions.len()
}
#[must_use]
pub fn is_clean(&self) -> bool {
!self
.warnings
.iter()
.any(|w| w.severity >= WarningSeverity::Warning)
}
#[must_use]
pub fn all_legal_refs(&self) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
self.positions
.iter()
.flat_map(|p| p.trace.legal_refs.iter().map(|r| r.citation()))
.filter(|c| seen.insert(c.clone()))
.collect()
}
#[must_use]
pub fn recomputed_total(&self) -> Decimal {
self.positions
.iter()
.map(|p| p.net_eur)
.sum::<Decimal>()
.round_dp(2)
}
}
#[derive(Debug, Clone)]
pub struct NneInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub period: SettlementPeriod,
pub letztverbrauchergruppe: crate::umlagen::Letztverbrauchergruppe,
pub sect19_umlage_ct_per_kwh: Option<Decimal>,
pub offshore_umlage_ct_per_kwh: Option<Decimal>,
pub kwkg_umlage_ct_per_kwh: Option<Decimal>,
pub tariff_sheet_id: Option<String>,
pub sparte: Sparte,
#[doc = "§14a Modul 3 per-interval input data."]
pub arbeitspreis: ArbeitspreisModell,
pub leistungspreis: Option<Leistungspreis>,
pub grundpreis: Option<Grundpreis>,
pub konzessionsabgabe: Option<Konzessionsabgabe>,
pub netzebene: Option<crate::netzebene::Netzebene>,
pub jahreshoechstleistung_kw: Option<Decimal>,
pub jahresarbeit_kwh: Option<Decimal>,
pub sect19: Option<crate::sect19::Sect19Vereinbarung>,
pub gas_kapazitaet: Option<crate::gas::GasKapazitaet>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct Sect14aModul3Interval {
pub period_from: time::OffsetDateTime,
pub period_to: time::OffsetDateTime,
pub menge_kwh: Decimal,
pub nne_rate_ct_per_kwh: Decimal,
pub epex_spot_ct_per_kwh: Option<Decimal>,
}
#[derive(Debug, Clone)]
pub struct MmmInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub period: SettlementPeriod,
pub sparte: Sparte,
pub actual_kwh: Decimal,
pub profil_kwh: Decimal,
pub mehr_preis_ct_per_kwh: Decimal,
pub minder_preis_ct_per_kwh: Decimal,
}
#[derive(Debug, Clone)]
pub struct MsbInput {
pub malo_id: String,
pub nb_mp_id: String,
pub msb_mp_id: String,
pub period: SettlementPeriod,
pub grundgebuehr_eur_per_month: Decimal,
pub billing_months: u32,
pub messdienstleistung_eur: Option<Decimal>,
pub messstellen_kategorie: Option<crate::msbg::MessstellenKategorie>,
pub entgeltschuldner: Option<crate::msbg::Entgeltschuldner>,
}
#[derive(Debug, Clone)]
pub struct GasAwhInput {
pub malo_id: String,
pub nb_mp_id: String,
pub lf_mp_id: String,
pub period: SettlementPeriod,
pub tariff_sheet_id: Option<String>,
pub awh_positionen: Vec<AwhPositionInput>,
}
#[derive(Debug, Clone)]
pub struct AwhPositionInput {
pub beschreibung: String,
pub anzahl: u32,
pub preis_eur: Decimal,
pub artikel_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub warnings: Vec<SettlementWarning>,
}
impl ValidationResult {
#[must_use]
pub fn ok() -> Self {
Self {
is_valid: true,
warnings: Vec::new(),
}
}
pub fn push(&mut self, w: SettlementWarning) {
if w.severity == WarningSeverity::Error {
self.is_valid = false;
}
self.warnings.push(w);
}
}
#[must_use]
pub fn validate_mmm_input(input: &MmmInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period.from() >= input.period.to() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.mehr_preis_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "NEGATIVE_MEHR_PREIS",
message: format!(
"mehr_preis_ct_per_kwh is negative: {}",
input.mehr_preis_ct_per_kwh
),
});
}
if input.minder_preis_ct_per_kwh < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Warning,
code: "NEGATIVE_MINDER_PREIS",
message: format!(
"minder_preis_ct_per_kwh is negative: {}",
input.minder_preis_ct_per_kwh
),
});
}
r
}
#[must_use]
pub fn validate_msb_input(input: &MsbInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period.from() >= input.period.to() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.grundgebuehr_eur_per_month < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "NEGATIVE_GRUNDGEBUEHR",
message: format!(
"grundgebuehr_eur_per_month is negative: {}",
input.grundgebuehr_eur_per_month
),
});
}
if input.billing_months == 0 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "ZERO_BILLING_MONTHS",
message: "billing_months must be at least 1".to_owned(),
});
}
r
}
#[must_use]
pub fn validate_gas_awh_input(input: &GasAwhInput) -> ValidationResult {
let mut r = ValidationResult::ok();
if input.period.from() >= input.period.to() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "INVALID_PERIOD",
message: "period_from must be strictly before period_to".to_owned(),
});
}
if input.awh_positionen.is_empty() {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "EMPTY_AWH_POSITIONEN",
message: "awh_positionen must contain at least one position".to_owned(),
});
}
for (i, awh) in input.awh_positionen.iter().enumerate() {
if awh.anzahl == 0 {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "ZERO_AWH_ANZAHL",
message: format!("awh_positionen[{i}].anzahl must be ≥ 1"),
});
}
if awh.preis_eur < Decimal::ZERO {
r.push(SettlementWarning {
severity: WarningSeverity::Error,
code: "NEGATIVE_AWH_PREIS",
message: format!(
"awh_positionen[{i}].preis_eur must be non-negative, got {}",
awh.preis_eur
),
});
}
}
r
}
#[cfg(test)]
mod input_model_tests {
use super::*;
use rust_decimal::dec;
#[test]
fn a_reduction_factor_must_actually_reduce() {
assert!(Reduktionsfaktor::new(dec!(0.85)).is_ok());
assert!(
Reduktionsfaktor::new(dec!(1)).is_ok(),
"no reduction is still valid"
);
assert!(
Reduktionsfaktor::new(dec!(0)).is_err(),
"zero is not a reduction"
);
assert!(Reduktionsfaktor::new(dec!(-0.5)).is_err());
assert!(
Reduktionsfaktor::new(dec!(5)).is_err(),
"5x is not a reduction"
);
assert_eq!(Reduktionsfaktor::REGELFALL.get(), dec!(0.85));
}
#[test]
fn every_model_reports_the_energy_it_priced() {
let flat = ArbeitspreisModell::Einheitlich(MengePreis {
menge_kwh: dec!(1000),
preis_ct_per_kwh: dec!(3.5),
});
assert_eq!(flat.menge_kwh(), dec!(1000));
let tou = ArbeitspreisModell::Modul2ZeitVariabel {
ht: MengePreis {
menge_kwh: dec!(600),
preis_ct_per_kwh: dec!(4.0),
},
nt: MengePreis {
menge_kwh: dec!(400),
preis_ct_per_kwh: dec!(1.5),
},
};
assert_eq!(tou.menge_kwh(), dec!(1000), "HT + NT, not one of them");
let modul1 = ArbeitspreisModell::Modul1Pauschal {
basis: MengePreis {
menge_kwh: dec!(1000),
preis_ct_per_kwh: dec!(3.5),
},
reduktion: Reduktionsfaktor::REGELFALL,
};
assert_eq!(
modul1.menge_kwh(),
dec!(1000),
"the reduction changes the rate, not the energy"
);
}
#[test]
fn a_model_carries_at_most_one_sect14a_module() {
use Sect14aModule as M;
let cases = [
(
ArbeitspreisModell::Einheitlich(MengePreis {
menge_kwh: dec!(1),
preis_ct_per_kwh: dec!(1),
}),
None,
),
(
ArbeitspreisModell::Modul1Pauschal {
basis: MengePreis {
menge_kwh: dec!(1),
preis_ct_per_kwh: dec!(1),
},
reduktion: Reduktionsfaktor::REGELFALL,
},
Some(M::Modul1),
),
(
ArbeitspreisModell::Modul2ZeitVariabel {
ht: MengePreis {
menge_kwh: dec!(1),
preis_ct_per_kwh: dec!(1),
},
nt: MengePreis {
menge_kwh: dec!(1),
preis_ct_per_kwh: dec!(1),
},
},
Some(M::Modul2),
),
(
ArbeitspreisModell::Modul3Spotpreis { intervalle: vec![] },
Some(M::Modul3),
),
];
for (model, expected) in cases {
assert_eq!(model.sect14a_modul(), expected);
}
}
#[test]
fn a_period_cannot_be_inverted() {
use time::macros::date;
assert!(SettlementPeriod::new(date!(2026 - 01 - 31), date!(2026 - 01 - 01)).is_err());
let one_day = SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 01))
.expect("a single day is a period");
assert_eq!(one_day.days(), 1);
let january =
SettlementPeriod::new(date!(2026 - 01 - 01), date!(2026 - 01 - 31)).expect("valid");
assert_eq!(january.days(), 31, "both bounds are inclusive");
}
}