use std::collections::BTreeSet;
use rust_decimal::Decimal;
use time::{Date, Duration, Month, OffsetDateTime};
use super::{EegError, sum};
use crate::series::Series;
use crate::time::calendar::DayBoundary;
use crate::time::resolution::Resolution;
pub const VOLLLASTVIERTELSTUNDEN: [u32; 12] =
[87, 189, 340, 442, 490, 508, 498, 453, 371, 231, 118, 73];
#[must_use]
pub const fn volllastviertelstunden_of(month: Month) -> u32 {
VOLLLASTVIERTELSTUNDEN[month as usize - 1]
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct NegativePrices {
starts: BTreeSet<OffsetDateTime>,
}
impl NegativePrices {
pub fn new(starts: impl IntoIterator<Item = OffsetDateTime>) -> Result<Self, EegError> {
let mut set = BTreeSet::new();
for at in starts {
let bucket = DayBoundary::Strom
.bucket(at, Resolution::QUARTER_HOUR)
.ok_or(EegError::Calendar)?;
if bucket.start() != at {
return Err(EegError::NotQuarterHour { at });
}
set.insert(at);
}
Ok(Self { starts: set })
}
#[must_use]
pub fn contains(&self, at: OffsetDateTime) -> bool {
self.starts.contains(&at)
}
#[must_use]
pub fn count(&self, from: OffsetDateTime, to: OffsetDateTime) -> u32 {
if from >= to {
return 0;
}
u32::try_from(self.starts.range(from..to).count()).unwrap_or(u32::MAX)
}
#[must_use]
pub fn count_in_year(&self, year: i32) -> Option<u32> {
let y = DayBoundary::Strom.year(year)?;
Some(self.count(y.start(), y.end()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ZeroValueCount {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub quarter_hours: u32,
}
pub fn zero_value_quarter_hours(
negative: &NegativePrices,
commissioning: OffsetDateTime,
exempt_through_year: Option<i32>,
) -> Result<ZeroValueCount, EegError> {
let first_year = DayBoundary::Strom
.day_of(commissioning)
.ok_or(EegError::Calendar)?
.year();
let last = first_year.checked_add(19).ok_or(EegError::Calendar)?;
let to = DayBoundary::Strom
.year(last)
.ok_or(EegError::Calendar)?
.end();
let from = match exempt_through_year {
Some(y) => {
let end = DayBoundary::Strom.year(y).ok_or(EegError::Calendar)?.end();
end.max(commissioning)
}
None => commissioning,
};
Ok(ZeroValueCount {
from,
to,
quarter_hours: negative.count(from, to),
})
}
const QUARTER_HOURS_PER_DAY: u32 = 96;
#[must_use]
pub const fn extension_days(quarter_hours: u32) -> u32 {
quarter_hours.div_ceil(QUARTER_HOURS_PER_DAY)
}
#[must_use]
pub const fn volllastviertelstunden(quarter_hours: u32) -> u32 {
quarter_hours.div_ceil(2)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct MonthDraw {
pub year: i32,
pub month: Month,
pub available: Decimal,
pub drawn: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SolarExtension {
pub budget: u32,
pub months: Vec<MonthDraw>,
pub end: Date,
}
#[must_use]
pub fn solar_extension(original_end: Date, budget: u32) -> Option<SolarExtension> {
let mut left = Decimal::from(budget);
let mut months = Vec::new();
if left.is_zero() {
return Some(SolarExtension {
budget,
months,
end: original_end,
});
}
let (mut year, mut month) = (original_end.year(), original_end.month());
let days = Decimal::from(month.length(year));
let remaining = days.checked_sub(Decimal::from(original_end.day()))?;
let mut available = Decimal::from(volllastviertelstunden_of(month))
.checked_mul(remaining)?
.checked_div(days)?;
loop {
if available > Decimal::ZERO {
let drawn = available.min(left);
left = left.checked_sub(drawn)?;
months.push(MonthDraw {
year,
month,
available,
drawn,
});
if left.is_zero() {
let end = Date::from_calendar_date(year, month, month.length(year)).ok()?;
return Some(SolarExtension {
budget,
months,
end,
});
}
}
if month == Month::December {
year = year.checked_add(1)?;
}
month = month.next();
available = Decimal::from(volllastviertelstunden_of(month));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct NegativeSpan {
pub from: OffsetDateTime,
pub to: OffsetDateTime,
pub quarter_hours: u32,
pub kwh: Decimal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SpanEnergy {
pub spans: Vec<NegativeSpan>,
pub total: Decimal,
}
pub fn negative_span_energy(
feed_in: &Series,
negative: &NegativePrices,
year: i32,
month: Month,
) -> Result<SpanEnergy, EegError> {
if feed_in.resolution() != Resolution::QUARTER_HOUR {
return Err(EegError::Resolution {
series: "feed-in",
found: feed_in.resolution(),
});
}
let period = DayBoundary::Strom
.month(year, month)
.ok_or(EegError::Calendar)?;
let step = Duration::minutes(15);
let mut spans: Vec<NegativeSpan> = Vec::new();
for &at in negative.starts.range(period.start()..period.end()) {
let iv = feed_in.get(at).ok_or(EegError::Missing {
series: "feed-in",
at,
})?;
if !iv.quality().is_billable() {
return Err(EegError::NotBillable {
series: "feed-in",
at,
});
}
match spans.last_mut() {
Some(span) if span.to == at => {
span.to = at + step;
span.quarter_hours += 1;
span.kwh = span.kwh.checked_add(iv.value()).ok_or(EegError::Overflow)?;
}
_ => spans.push(NegativeSpan {
from: at,
to: at + step,
quarter_hours: 1,
kwh: iv.value(),
}),
}
}
let total = sum(spans.iter().map(|s| s.kwh))?;
Ok(SpanEnergy { spans, total })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::series::interval::{MeterInterval, QualityFlag};
use rust_decimal::dec;
use time::macros::{date, datetime};
#[test]
fn the_monthly_table_is_the_statutes() {
assert_eq!(
VOLLLASTVIERTELSTUNDEN,
[87, 189, 340, 442, 490, 508, 498, 453, 371, 231, 118, 73]
);
assert_eq!(volllastviertelstunden_of(Month::January), 87);
assert_eq!(volllastviertelstunden_of(Month::December), 73);
}
#[test]
fn both_roundings_go_up() {
assert_eq!(extension_days(1), 1);
assert_eq!(extension_days(95), 1);
assert_eq!(extension_days(96), 1);
assert_eq!(extension_days(145), 2); assert_eq!(extension_days(100), 2); assert_eq!(volllastviertelstunden(1), 1);
assert_eq!(volllastviertelstunden(3), 2);
assert_eq!(volllastviertelstunden(0), 0);
}
#[test]
fn solar_extension_prorates_the_first_month_and_ends_at_month_end() {
let e = solar_extension(date!(2045 - 04 - 15), volllastviertelstunden(1000)).unwrap();
assert_eq!(e.budget, 500);
assert_eq!(e.months.len(), 2);
assert_eq!(e.months[0].available, dec!(221));
assert_eq!(e.months[0].drawn, dec!(221));
assert_eq!(e.months[1].month, Month::May);
assert_eq!(e.months[1].drawn, dec!(279));
assert_eq!(e.end, date!(2045 - 05 - 31));
}
#[test]
fn a_budget_exhausted_exactly_at_a_month_end_ends_that_month() {
let e = solar_extension(date!(2044 - 12 - 31), 276).unwrap();
assert_eq!(e.end, date!(2045 - 02 - 28));
let e = solar_extension(date!(2044 - 12 - 31), 277).unwrap();
assert_eq!(e.end, date!(2045 - 03 - 31));
let e = solar_extension(date!(2044 - 12 - 31), 0).unwrap();
assert_eq!(e.end, date!(2044 - 12 - 31));
assert!(e.months.is_empty());
}
#[test]
fn a_budget_used_inside_the_prorated_month_ends_that_month() {
let e = solar_extension(date!(2045 - 06 - 10), 300).unwrap();
assert_eq!(e.end, date!(2045 - 06 - 30));
assert_eq!(e.months.len(), 1);
assert_eq!(e.months[0].drawn, dec!(300));
}
fn year_set(starts: &[OffsetDateTime]) -> NegativePrices {
NegativePrices::new(starts.iter().copied()).unwrap()
}
#[test]
fn the_set_accepts_only_quarter_hour_starts() {
assert_eq!(
NegativePrices::new([datetime!(2026-06-01 10:05 UTC)]),
Err(EegError::NotQuarterHour {
at: datetime!(2026-06-01 10:05 UTC)
})
);
}
#[test]
fn the_repeated_hour_counts_each_utc_quarter_hour() {
let starts: Vec<_> = (0..8)
.map(|i| datetime!(2026-10-25 0:00 UTC) + Duration::minutes(15 * i))
.collect();
let set = year_set(&starts);
assert_eq!(set.count_in_year(2026), Some(8));
}
#[test]
fn the_window_runs_from_commissioning_through_the_nineteenth_year() {
let set = year_set(&[
datetime!(2026-03-01 11:00 UTC), datetime!(2026-07-01 11:00 UTC),
datetime!(2045-12-31 12:00 UTC), datetime!(2046-01-01 12:00 UTC), ]);
let c = zero_value_quarter_hours(&set, datetime!(2026-05-01 10:00 UTC), None).unwrap();
assert_eq!(c.quarter_hours, 2);
assert_eq!(c.to, datetime!(2045-12-31 23:00 UTC));
let c =
zero_value_quarter_hours(&set, datetime!(2026-05-01 10:00 UTC), Some(2026)).unwrap();
assert_eq!(c.quarter_hours, 1);
assert_eq!(c.from, datetime!(2026-12-31 23:00 UTC));
}
fn feed_in(start: OffsetDateTime, values: &[Decimal]) -> Series {
let ivs = values
.iter()
.enumerate()
.map(|(i, &v)| {
MeterInterval::quarter_hour(
start + Duration::minutes(15 * i as i64),
v,
QualityFlag::Measured,
)
.unwrap()
})
.collect();
Series::new(Resolution::QUARTER_HOUR, DayBoundary::Strom, ivs).unwrap()
}
#[test]
fn span_energy_groups_uninterrupted_runs_and_cuts_at_the_month() {
let t = datetime!(2026-05-31 21:30 UTC);
let series = feed_in(
t,
&[
dec!(1),
dec!(2),
dec!(3),
dec!(4),
dec!(5),
dec!(6),
dec!(7),
],
);
let q = |i: i64| t + Duration::minutes(15 * i);
let set = year_set(&[q(0), q(1), q(2), q(3), q(5), q(6)]);
let june = negative_span_energy(&series, &set, 2026, Month::June).unwrap();
assert_eq!(june.spans.len(), 2);
assert_eq!(june.spans[0].from, q(2));
assert_eq!(june.spans[0].quarter_hours, 2);
assert_eq!(june.spans[0].kwh, dec!(7));
assert_eq!(june.spans[1].kwh, dec!(13));
assert_eq!(june.total, dec!(20));
let may = negative_span_energy(&series, &set, 2026, Month::May).unwrap();
assert_eq!(may.total, dec!(3));
let gap = year_set(&[q(9)]);
assert!(matches!(
negative_span_energy(&series, &gap, 2026, Month::June),
Err(EegError::Missing { .. })
));
}
}