use serde::{Deserialize, Serialize};
use time::{Date, Month};
use mako_mabis::{BilanzierungsgebietId, BilanzkreisId};
use crate::error::EmobError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Regelzone {
FiftyHertz,
Amprion,
TenneT,
TransnetBw,
}
impl Regelzone {
pub const ALL: [Self; 4] = [
Self::FiftyHertz,
Self::Amprion,
Self::TenneT,
Self::TransnetBw,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::FiftyHertz => "50Hertz",
Self::Amprion => "Amprion",
Self::TenneT => "TenneT",
Self::TransnetBw => "TransnetBW",
}
}
}
impl std::fmt::Display for Regelzone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub const fn ist_monatserster(date: Date) -> bool {
date.day() == 1
}
#[must_use]
pub fn naechster_monatserster(date: Date) -> Date {
let (year, month) = if date.month() == Month::December {
(date.year() + 1, Month::January)
} else {
(date.year(), date.month().next())
};
Date::from_calendar_date(year, month, 1).expect("the first of a month always exists")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VirtualBalancingArea {
pub eic: BilanzierungsgebietId,
pub regelzone: Regelzone,
pub delta_bk: BilanzkreisId,
pub valid_from: Date,
pub valid_to: Option<Date>,
}
impl VirtualBalancingArea {
pub fn neu(
eic: BilanzierungsgebietId,
regelzone: Regelzone,
delta_bk: BilanzkreisId,
valid_from: Date,
) -> Result<Self, EmobError> {
if !ist_monatserster(valid_from) {
return Err(EmobError::NotFirstOfMonth {
was: "the Gültigkeitsbeginn of a Bilanzierungsgebiet",
date: valid_from,
});
}
Ok(Self {
eic,
regelzone,
delta_bk,
valid_from,
valid_to: None,
})
}
pub fn beenden(&mut self, letzter_tag: Date) -> Result<(), EmobError> {
let folgetag = letzter_tag.next_day().ok_or(EmobError::NotFirstOfMonth {
was: "the Beendigung of a Bilanzierungsgebiet",
date: letzter_tag,
})?;
if !ist_monatserster(folgetag) || folgetag <= self.valid_from {
return Err(EmobError::NotFirstOfMonth {
was: "the Beendigung of a Bilanzierungsgebiet",
date: letzter_tag,
});
}
self.valid_to = Some(folgetag);
Ok(())
}
#[must_use]
pub fn gilt_am(&self, date: Date) -> bool {
date >= self.valid_from && self.valid_to.is_none_or(|to| date < to)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BgRegistry {
bgs: Vec<VirtualBalancingArea>,
}
impl BgRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, bg: VirtualBalancingArea) -> Result<(), EmobError> {
if self.bgs.iter().any(|held| {
held.regelzone == bg.regelzone
&& held.valid_from < bg.valid_to.unwrap_or(Date::MAX)
&& bg.valid_from < held.valid_to.unwrap_or(Date::MAX)
}) {
return Err(EmobError::DoppeltesBilanzierungsgebiet {
regelzone: bg.regelzone.to_string(),
});
}
self.bgs.push(bg);
Ok(())
}
#[must_use]
pub fn at(&self, regelzone: Regelzone, date: Date) -> Option<&VirtualBalancingArea> {
self.bgs
.iter()
.find(|bg| bg.regelzone == regelzone && bg.gilt_am(date))
}
pub fn iter(&self) -> impl Iterator<Item = &VirtualBalancingArea> {
self.bgs.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u8, day: u8) -> Date {
Date::from_calendar_date(y, Month::try_from(m).unwrap(), day).unwrap()
}
fn bk() -> BilanzkreisId {
BilanzkreisId::new("11XSUEDWESTSTRO8").unwrap_or_else(|_| {
panic!("test Bilanzkreis id must parse")
})
}
fn bg_id() -> BilanzierungsgebietId {
BilanzierungsgebietId::new("11YN-0000-0001-Q").expect("test BG id must parse")
}
fn bg(from: Date) -> VirtualBalancingArea {
VirtualBalancingArea::neu(bg_id(), Regelzone::TenneT, bk(), from).expect("valid start")
}
#[test]
fn a_bg_starts_only_on_the_first() {
assert!(VirtualBalancingArea::neu(bg_id(), Regelzone::TenneT, bk(), d(2026, 9, 1)).is_ok());
assert!(
VirtualBalancingArea::neu(bg_id(), Regelzone::TenneT, bk(), d(2026, 9, 15)).is_err()
);
}
#[test]
fn a_bg_ends_on_a_monatsletzten() {
let mut b = bg(d(2026, 9, 1));
assert!(b.beenden(d(2026, 11, 15)).is_err(), "mid-month");
assert!(b.beenden(d(2026, 11, 30)).is_ok());
assert_eq!(b.valid_to, Some(d(2026, 12, 1)));
}
#[test]
fn validity_is_half_open() {
let mut b = bg(d(2026, 9, 1));
b.beenden(d(2026, 11, 30)).unwrap();
assert!(b.gilt_am(d(2026, 9, 1)));
assert!(b.gilt_am(d(2026, 11, 30)));
assert!(!b.gilt_am(d(2026, 12, 1)));
assert!(!b.gilt_am(d(2026, 8, 31)));
}
#[test]
fn one_bg_per_regelzone_at_a_time() {
let mut reg = BgRegistry::new();
reg.register(bg(d(2026, 9, 1))).unwrap();
let err = reg.register(bg(d(2027, 1, 1))).unwrap_err();
assert!(matches!(
err,
EmobError::DoppeltesBilanzierungsgebiet { .. }
));
}
#[test]
fn a_closed_bg_frees_the_regelzone() {
let mut reg = BgRegistry::new();
let mut first = bg(d(2026, 9, 1));
first.beenden(d(2026, 12, 31)).unwrap();
reg.register(first).unwrap();
reg.register(bg(d(2027, 1, 1))).expect("no overlap");
assert_eq!(reg.iter().count(), 2);
assert_eq!(
reg.at(Regelzone::TenneT, d(2026, 10, 1))
.unwrap()
.valid_from,
d(2026, 9, 1)
);
assert_eq!(
reg.at(Regelzone::TenneT, d(2027, 2, 1)).unwrap().valid_from,
d(2027, 1, 1)
);
}
#[test]
fn regelzonen_are_independent() {
let mut reg = BgRegistry::new();
for rz in Regelzone::ALL {
let b = VirtualBalancingArea::neu(bg_id(), rz, bk(), d(2026, 9, 1)).unwrap();
reg.register(b).expect("one per Regelzone");
}
assert_eq!(reg.iter().count(), 4);
}
#[test]
fn naechster_monatserster_crosses_the_year() {
assert_eq!(naechster_monatserster(d(2026, 12, 3)), d(2027, 1, 1));
assert_eq!(naechster_monatserster(d(2026, 1, 31)), d(2026, 2, 1));
}
}