use time::OffsetDateTime;
use crate::envelope::Envelope;
use crate::ids::{AssetId, PlanId};
use crate::slot::{Horizon, SLOT, Slot};
use crate::units::{Energy, Power};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AssetTarget {
pub asset: AssetId,
pub power: Power,
pub envelope: Envelope,
#[cfg_attr(feature = "serde", serde(default))]
pub marginal_eur_per_kwh: Option<f64>,
}
impl AssetTarget {
#[must_use]
pub fn fixed(asset: AssetId, power: Power) -> Self {
Self {
asset,
power,
envelope: Envelope::exactly(power),
marginal_eur_per_kwh: None,
}
}
#[must_use]
pub fn value_or(&self, slot_marginal: Option<f64>) -> Option<f64> {
self.marginal_eur_per_kwh.or(slot_marginal)
}
#[must_use]
pub fn energy(&self) -> Energy {
self.power.over(SLOT)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SlotPlan {
pub slot: Slot,
pub targets: Vec<AssetTarget>,
#[cfg_attr(feature = "serde", serde(default))]
pub marginal_eur_per_kwh: Option<f64>,
#[cfg_attr(feature = "serde", serde(default))]
pub flexibility_eur_per_kwh: Option<f64>,
}
impl SlotPlan {
#[must_use]
pub fn target(&self, asset: &AssetId) -> Option<&AssetTarget> {
self.targets.iter().find(|t| &t.asset == asset)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CostBreakdown {
pub energy_eur: f64,
pub wear_eur: f64,
pub curtailment_eur: f64,
pub discomfort_eur: f64,
#[cfg_attr(feature = "serde", serde(default))]
pub stored_eur: f64,
}
impl CostBreakdown {
#[must_use]
pub fn energy_only(energy_eur: f64) -> Self {
Self {
energy_eur,
..Self::default()
}
}
#[must_use]
pub fn total(&self) -> f64 {
self.energy_eur
+ self.wear_eur
+ self.curtailment_eur
+ self.discomfort_eur
+ self.stored_eur
}
#[must_use]
pub fn billed_eur(&self) -> f64 {
self.energy_eur
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Plan {
pub id: PlanId,
#[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339"))]
pub created_at: OffsetDateTime,
pub horizon: Horizon,
pub slots: Vec<SlotPlan>,
#[cfg_attr(feature = "serde", serde(default))]
pub expected_cost: Option<CostBreakdown>,
#[cfg_attr(feature = "serde", serde(default))]
pub baseline_cost: Option<CostBreakdown>,
}
impl Plan {
#[must_use]
pub fn empty(horizon: Horizon, created_at: OffsetDateTime) -> Self {
Self {
id: PlanId::new(),
created_at,
horizon,
slots: Vec::new(),
expected_cost: None,
baseline_cost: None,
}
}
#[must_use]
pub fn slot_at(&self, instant: OffsetDateTime) -> Option<&SlotPlan> {
let slot = Slot::containing(instant);
self.slots.iter().find(|s| s.slot == slot)
}
#[must_use]
pub fn age(&self, now: OffsetDateTime) -> time::Duration {
now - self.created_at
}
#[must_use]
pub fn is_stale(&self, now: OffsetDateTime, max_age: time::Duration) -> bool {
self.age(now) > max_age
}
#[must_use]
pub fn expected_saving_eur(&self) -> Option<f64> {
Some(self.baseline_cost?.total() - self.expected_cost?.total())
}
#[must_use]
pub fn expected_bill_saving_eur(&self) -> Option<f64> {
Some(self.baseline_cost?.billed_eur() - self.expected_cost?.billed_eur())
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
const T0: OffsetDateTime = datetime!(2026-05-01 10:00:00 UTC);
fn plan() -> Plan {
let horizon = Horizon::new(T0, 4);
let asset = AssetId::new("battery").unwrap();
Plan {
slots: horizon
.slots()
.map(|slot| SlotPlan {
flexibility_eur_per_kwh: None,
slot,
targets: vec![AssetTarget::fixed(asset.clone(), Power::from_kw(4.0))],
marginal_eur_per_kwh: Some(0.28),
})
.collect(),
expected_cost: Some(CostBreakdown {
energy_eur: 2.8,
wear_eur: 0.2,
..CostBreakdown::default()
}),
baseline_cost: Some(CostBreakdown::energy_only(4.2)),
..Plan::empty(horizon, T0)
}
}
#[test]
fn a_plan_finds_the_slot_for_an_instant() {
let p = plan();
let s = p.slot_at(T0 + time::Duration::minutes(20)).unwrap();
assert_eq!(s.slot, Slot::containing(T0 + time::Duration::minutes(15)));
assert!(p.slot_at(T0 + time::Duration::hours(5)).is_none());
}
#[test]
fn a_target_converts_power_into_the_energy_the_arbiter_follows() {
let t = AssetTarget::fixed(AssetId::new("battery").unwrap(), Power::from_kw(4.0));
assert!(
(t.energy().kwh() - 1.0).abs() < 1e-12,
"4 kW for 15 minutes is 1 kWh"
);
}
#[test]
fn staleness_is_the_callers_choice() {
let p = plan();
assert!(!p.is_stale(T0 + time::Duration::minutes(5), time::Duration::minutes(10)));
assert!(p.is_stale(
T0 + time::Duration::minutes(20),
time::Duration::minutes(10)
));
}
#[test]
fn the_expected_saving_needs_both_numbers() {
assert!((plan().expected_saving_eur().unwrap() - 1.2).abs() < 1e-9);
let mut p = plan();
p.baseline_cost = None;
assert_eq!(p.expected_saving_eur(), None);
}
#[test]
fn wear_is_counted_against_the_plan_and_not_against_the_baseline() {
let p = plan();
assert!((p.expected_bill_saving_eur().unwrap() - 1.4).abs() < 1e-9);
assert!((p.expected_saving_eur().unwrap() - 1.2).abs() < 1e-9);
assert!(p.expected_saving_eur() < p.expected_bill_saving_eur());
}
}