use core::fmt;
use time::OffsetDateTime;
use crate::error::SetpointError;
use crate::ids::{AssetId, PlanId};
use crate::slot::Slot;
use crate::units::{Current, Power};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(rename_all = "snake_case", tag = "kind", content = "value")
)]
pub enum Command {
ActivePower(Power),
ConsumptionCeiling(Power),
ProductionCeiling(Power),
ChargingCurrent(Current),
PhaseCount(u8),
OperationMode(u8),
OnOff(bool),
}
impl Command {
#[must_use]
pub fn is_finite(&self) -> bool {
match self {
Command::ActivePower(p)
| Command::ConsumptionCeiling(p)
| Command::ProductionCeiling(p) => p.is_finite(),
Command::ChargingCurrent(c) => c.is_finite(),
Command::PhaseCount(_) | Command::OperationMode(_) | Command::OnOff(_) => true,
}
}
}
impl fmt::Display for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Command::ActivePower(p) => write!(f, "active power {p}"),
Command::ConsumptionCeiling(p) => write!(f, "consume at most {p}"),
Command::ProductionCeiling(p) => write!(f, "feed in at most {p}"),
Command::ChargingCurrent(c) => write!(f, "charging current {c}"),
Command::PhaseCount(n) => write!(f, "{n}-phase"),
Command::OperationMode(m) => write!(f, "operation mode {m}"),
Command::OnOff(true) => f.write_str("on"),
Command::OnOff(false) => f.write_str("off"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum GuardRule {
Lpc,
Lpp,
Para9Cap,
Failsafe,
CircuitLimit,
ContractLimit,
Unbalance,
DeviceLimit,
BackupReserve,
}
impl fmt::Display for GuardRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
GuardRule::Lpc => "§ 14a EnWG limit",
GuardRule::Lpp => "§ 9 EEG feed-in limit",
GuardRule::Para9Cap => "§ 9 EEG 60 % cap",
GuardRule::Failsafe => "EEBUS failsafe",
GuardRule::CircuitLimit => "circuit limit",
GuardRule::ContractLimit => "connection limit",
GuardRule::Unbalance => "unbalance limit",
GuardRule::DeviceLimit => "device limit",
GuardRule::BackupReserve => "backup reserve",
};
f.write_str(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum RealtimeCause {
SurplusTracking,
SelfConsumption,
PhaseBalance,
RampLimit,
Hysteresis,
LocalControl,
MaximumPowerPoint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum UserOverride {
Boost,
Pause,
Away,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum FallbackCause {
NoPlan,
PlanStale,
DriverLost,
ClockUnsynchronised,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "source"))]
pub enum Reason {
Guard {
rule: GuardRule,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
#[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339::option"))]
since: Option<OffsetDateTime>,
},
User(UserOverride),
Plan {
plan: PlanId,
slot: Slot,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
marginal_eur_per_kwh: Option<f64>,
},
Realtime(RealtimeCause),
Fallback(FallbackCause),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Authority {
Fallback = 0,
Realtime = 1,
Plan = 2,
User = 3,
Guard = 4,
}
impl Reason {
#[must_use]
pub const fn authority(&self) -> Authority {
match self {
Reason::Guard { .. } => Authority::Guard,
Reason::User(_) => Authority::User,
Reason::Plan { .. } => Authority::Plan,
Reason::Realtime(_) => Authority::Realtime,
Reason::Fallback(_) => Authority::Fallback,
}
}
#[must_use]
pub const fn guard(rule: GuardRule) -> Self {
Reason::Guard { rule, since: None }
}
#[must_use]
pub const fn guard_since(rule: GuardRule, since: OffsetDateTime) -> Self {
Reason::Guard {
rule,
since: Some(since),
}
}
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Reason::Guard {
rule,
since: Some(t),
} => write!(f, "{rule} (since {t})"),
Reason::Guard { rule, since: None } => write!(f, "{rule}"),
Reason::User(o) => write!(f, "user: {o:?}"),
Reason::Plan {
slot,
marginal_eur_per_kwh: Some(m),
..
} => {
write!(f, "plan for {slot} ({m:.4} €/kWh)")
}
Reason::Plan { slot, .. } => write!(f, "plan for {slot}"),
Reason::Realtime(c) => write!(f, "realtime: {c:?}"),
Reason::Fallback(c) => write!(f, "fallback: {c:?}"),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Setpoint {
pub asset: AssetId,
pub command: Command,
pub reason: Reason,
#[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339"))]
pub at: OffsetDateTime,
}
impl Setpoint {
pub fn new(
asset: AssetId,
command: Command,
reason: Reason,
at: OffsetDateTime,
) -> Result<Self, SetpointError> {
if !command.is_finite() {
return Err(SetpointError::NotFinite {
asset: asset.to_string(),
});
}
Ok(Self {
asset,
command,
reason,
at,
})
}
#[must_use]
pub const fn authority(&self) -> Authority {
self.reason.authority()
}
}
impl fmt::Display for Setpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {} — {}", self.asset, self.command, self.reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
const NOW: OffsetDateTime = datetime!(2026-05-01 12:00:00 UTC);
#[test]
fn a_non_finite_command_never_becomes_a_setpoint() {
let asset = AssetId::new("wallbox").unwrap();
let err = Setpoint::new(
asset.clone(),
Command::ActivePower(Power::new_const(f64::NAN)),
Reason::guard(GuardRule::Lpc),
NOW,
)
.unwrap_err();
assert_eq!(
err,
SetpointError::NotFinite {
asset: "wallbox".into()
}
);
}
#[test]
fn guard_outranks_every_other_reason() {
let guard = Reason::guard(GuardRule::Lpc).authority();
for other in [
Reason::User(UserOverride::Boost).authority(),
Reason::Plan {
plan: PlanId::new(),
slot: Slot::containing(NOW),
marginal_eur_per_kwh: None,
}
.authority(),
Reason::Realtime(RealtimeCause::SurplusTracking).authority(),
Reason::Fallback(FallbackCause::NoPlan).authority(),
] {
assert!(guard > other, "guard must outrank {other:?}");
}
}
#[test]
fn a_setpoint_explains_itself() {
let sp = Setpoint::new(
AssetId::new("wallbox").unwrap(),
Command::ConsumptionCeiling(Power::from_kw(4.2)),
Reason::guard_since(GuardRule::Lpc, NOW),
NOW,
)
.unwrap();
let rendered = sp.to_string();
assert!(rendered.contains("wallbox"), "{rendered}");
assert!(rendered.contains("consume at most"), "{rendered}");
assert!(rendered.contains("§ 14a"), "{rendered}");
}
}