Skip to main content

hems_core/
setpoint.rs

1//! Commands, and the reason each one exists.
2//!
3//! A setpoint without a reason is unexplainable after the fact, and "why did my
4//! wallbox stop at 17:04?" is the single most common question a HEMS has to
5//! answer — to the customer, to the installer, and to the network operator, who
6//! may ask an operator to show that a § 14a reduction was actually carried out
7//! (`[BK6-22-300 A1 7.2]`).
8//!
9//! So [`Setpoint::new`] is the only constructor and it takes a [`Reason`]. There
10//! is no way to produce a command that cannot say where it came from.
11
12use core::fmt;
13
14use time::OffsetDateTime;
15
16use crate::error::SetpointError;
17use crate::ids::{AssetId, PlanId};
18use crate::slot::Slot;
19use crate::units::{Current, Power};
20
21/// What an asset is being told to do.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(
25    feature = "serde",
26    serde(rename_all = "snake_case", tag = "kind", content = "value")
27)]
28pub enum Command {
29    /// Track this active power, load convention: positive draws, negative feeds
30    /// in or discharges.
31    ActivePower(Power),
32    /// Draw no more than this. A ceiling, not a target — the asset is free to
33    /// use less, which is what every § 14a and § 9 EEG limit actually says.
34    ConsumptionCeiling(Power),
35    /// Feed in no more than this magnitude (a non-negative value).
36    ProductionCeiling(Power),
37    /// Charging current per used conductor (the unit a wallbox speaks).
38    ChargingCurrent(Current),
39    /// Use this many outer conductors (1 or 3).
40    PhaseCount(u8),
41    /// Enter this discrete operating mode. The `u8` is the SG Ready state 1–4
42    /// or the index of an S2 `OMBC` operation mode.
43    OperationMode(u8),
44    /// Switch the asset on or off.
45    OnOff(bool),
46}
47
48impl Command {
49    /// `true` when every number in the command is finite.
50    #[must_use]
51    pub fn is_finite(&self) -> bool {
52        match self {
53            Command::ActivePower(p)
54            | Command::ConsumptionCeiling(p)
55            | Command::ProductionCeiling(p) => p.is_finite(),
56            Command::ChargingCurrent(c) => c.is_finite(),
57            Command::PhaseCount(_) | Command::OperationMode(_) | Command::OnOff(_) => true,
58        }
59    }
60}
61
62impl fmt::Display for Command {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Command::ActivePower(p) => write!(f, "active power {p}"),
66            Command::ConsumptionCeiling(p) => write!(f, "consume at most {p}"),
67            Command::ProductionCeiling(p) => write!(f, "feed in at most {p}"),
68            Command::ChargingCurrent(c) => write!(f, "charging current {c}"),
69            Command::PhaseCount(n) => write!(f, "{n}-phase"),
70            Command::OperationMode(m) => write!(f, "operation mode {m}"),
71            Command::OnOff(true) => f.write_str("on"),
72            Command::OnOff(false) => f.write_str("off"),
73        }
74    }
75}
76
77/// The grid rules the guard plane can invoke.
78///
79/// The variants live here, in the dependency-free core, so that a [`Reason`]
80/// can name a rule without `hems-core` depending on `hems-grid`. `hems-grid`
81/// owns the rules' *content*; this enum is only their identity, and it is what
82/// the UI, the evidence record and the operator see.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
86pub enum GuardRule {
87    /// § 14a EnWG netzorientierte Steuerung: the network operator's limit on the
88    /// netzwirksamer Leistungsbezug, received over EEBUS LPC or a relay.
89    Lpc,
90    /// § 9 EEG: the network operator's limit on feed-in, over EEBUS LPP.
91    Lpp,
92    /// § 9 EEG Solarspitzengesetz: the 60 % cap that applies until an intelligent
93    /// metering system with a control device is in operation.
94    Para9Cap,
95    /// The EEBUS failsafe state, entered when the Energy Guard's heartbeat stops.
96    Failsafe,
97    /// A fuse or cable rating on the circuit path.
98    CircuitLimit,
99    /// The contractually agreed connection power.
100    ContractLimit,
101    /// The VDE-AR-N 4100 limit on unbalanced load (4,6 kVA) — the limit on the
102    /// *installation*, so it binds the single-phase devices that can move it.
103    Unbalance,
104    /// The asset's own nameplate, rating, or state of charge.
105    DeviceLimit,
106    /// The energy held back for a power cut. Neither the planner nor the arbiter
107    /// may discharge below it; only an islanded system may use it.
108    BackupReserve,
109}
110
111impl fmt::Display for GuardRule {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        let s = match self {
114            GuardRule::Lpc => "§ 14a EnWG limit",
115            GuardRule::Lpp => "§ 9 EEG feed-in limit",
116            GuardRule::Para9Cap => "§ 9 EEG 60 % cap",
117            GuardRule::Failsafe => "EEBUS failsafe",
118            GuardRule::CircuitLimit => "circuit limit",
119            GuardRule::ContractLimit => "connection limit",
120            GuardRule::Unbalance => "unbalance limit",
121            GuardRule::DeviceLimit => "device limit",
122            GuardRule::BackupReserve => "backup reserve",
123        };
124        f.write_str(s)
125    }
126}
127
128/// Why the arbiter chose to correct a plan value inside a slot.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
131#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
132pub enum RealtimeCause {
133    /// Following the measured surplus rather than the forecast one.
134    SurplusTracking,
135    /// Covering a measured import from the store rather than from the grid.
136    ///
137    /// The other half of surplus tracking, and the behaviour that has to hold
138    /// when there is no plan at all: a box with a full battery that imports the
139    /// evening peak because its planner is gone is worse than one with no
140    /// planner in the first place.
141    SelfConsumption,
142    /// Keeping the site's unbalance inside its limit.
143    PhaseBalance,
144    /// Ramping towards the target rather than jumping to it.
145    RampLimit,
146    /// Holding the previous value because the change was too small to be worth
147    /// sending — a device asked to chase measurement noise wears out its relays
148    /// for nothing.
149    Hysteresis,
150    /// A device left to its own controls, because nothing is limiting it.
151    ///
152    /// A heat pump has a thermostat and a hot-water tank has a sensor. An energy
153    /// manager only ever tells them to use *less*, so an absent instruction
154    /// means "no limit" — not "off". Reading it as "off", which is right for a
155    /// battery and a charge point, is how a box with no plan spent a January day
156    /// letting the house go cold and a June day handing out cold showers, while
157    /// reporting a saving for both.
158    LocalControl,
159    /// A generator running at everything the weather offers, because nothing is
160    /// limiting it.
161    ///
162    /// The default for an inverter, and it has to be said out loud. Everything
163    /// else on a site answers a request for *more* power; an inverter answers a
164    /// request for *less*, so reading its absent instruction as "zero" — the way
165    /// an absent instruction reads for every load — tells the inverter to
166    /// stop.
167    MaximumPowerPoint,
168}
169
170/// A deliberate human override.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
174pub enum UserOverride {
175    /// "Charge now", "heat now" — ignore price, respect the guard.
176    Boost,
177    /// Hold the asset off until further notice.
178    Pause,
179    /// The household is away; comfort constraints are relaxed.
180    Away,
181}
182
183/// Why no plan was followed.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
185#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
186#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
187pub enum FallbackCause {
188    /// No plan covers the current slot.
189    NoPlan,
190    /// The plan is older than the operator allows it to be.
191    PlanStale,
192    /// The asset's driver is not reporting.
193    DriverLost,
194    /// The system clock is not synchronised, so price and window rules cannot be
195    /// trusted. Grid rules still apply; tariff optimisation pauses.
196    ClockUnsynchronised,
197}
198
199/// Why a setpoint has the value it has.
200///
201/// The variants are ordered by authority: a [`Reason::Guard`] value can never be
202/// relaxed by anything below it. [`Reason::authority`] makes that order
203/// machine-checkable, and `hems-realtime` asserts it on every tick.
204#[derive(Debug, Clone, Copy, PartialEq)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
206#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "source"))]
207pub enum Reason {
208    /// A grid or safety rule. Absolute — `[BK6-22-300 A1 4.6 S. 3]` requires that
209    /// a network operator's reduction takes precedence over market-driven
210    /// control whenever it is the stricter of the two.
211    Guard {
212        /// Which rule.
213        rule: GuardRule,
214        /// When the rule became active, where that is known.
215        #[cfg_attr(
216            feature = "serde",
217            serde(default, skip_serializing_if = "Option::is_none")
218        )]
219        #[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339::option"))]
220        since: Option<OffsetDateTime>,
221    },
222    /// A person asked for it.
223    User(UserOverride),
224    /// The optimiser's plan for this slot.
225    Plan {
226        /// Which plan.
227        plan: PlanId,
228        /// Which slot of it.
229        slot: Slot,
230        /// The marginal value of a kilowatt-hour in that slot, in €/kWh.
231        ///
232        /// The price the plan faces in that slot, not a shadow price: a true
233        /// dual needs a solver that exposes them, and the pure-Rust backend
234        /// does not. It explains *how much* the plan cared, and it is what the
235        /// guard weights a § 14a allocation by.
236        #[cfg_attr(
237            feature = "serde",
238            serde(default, skip_serializing_if = "Option::is_none")
239        )]
240        marginal_eur_per_kwh: Option<f64>,
241    },
242    /// A correction inside the slot the plan could not anticipate.
243    Realtime(RealtimeCause),
244    /// No plan was available.
245    Fallback(FallbackCause),
246}
247
248/// How much authority a reason carries. Larger wins.
249#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
250pub enum Authority {
251    /// A fallback default.
252    Fallback = 0,
253    /// A correction within the plan's intent.
254    Realtime = 1,
255    /// The optimiser's plan.
256    Plan = 2,
257    /// A person's explicit wish.
258    User = 3,
259    /// A grid or safety rule. Nothing overrides it.
260    Guard = 4,
261}
262
263impl Reason {
264    /// The authority this reason carries.
265    #[must_use]
266    pub const fn authority(&self) -> Authority {
267        match self {
268            Reason::Guard { .. } => Authority::Guard,
269            Reason::User(_) => Authority::User,
270            Reason::Plan { .. } => Authority::Plan,
271            Reason::Realtime(_) => Authority::Realtime,
272            Reason::Fallback(_) => Authority::Fallback,
273        }
274    }
275
276    /// A guard reason without a start time.
277    #[must_use]
278    pub const fn guard(rule: GuardRule) -> Self {
279        Reason::Guard { rule, since: None }
280    }
281
282    /// A guard reason that knows when it started.
283    #[must_use]
284    pub const fn guard_since(rule: GuardRule, since: OffsetDateTime) -> Self {
285        Reason::Guard {
286            rule,
287            since: Some(since),
288        }
289    }
290}
291
292impl fmt::Display for Reason {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            Reason::Guard {
296                rule,
297                since: Some(t),
298            } => write!(f, "{rule} (since {t})"),
299            Reason::Guard { rule, since: None } => write!(f, "{rule}"),
300            Reason::User(o) => write!(f, "user: {o:?}"),
301            Reason::Plan {
302                slot,
303                marginal_eur_per_kwh: Some(m),
304                ..
305            } => {
306                write!(f, "plan for {slot} ({m:.4} €/kWh)")
307            }
308            Reason::Plan { slot, .. } => write!(f, "plan for {slot}"),
309            Reason::Realtime(c) => write!(f, "realtime: {c:?}"),
310            Reason::Fallback(c) => write!(f, "fallback: {c:?}"),
311        }
312    }
313}
314
315/// One command for one asset, with its reason and its time.
316#[derive(Debug, Clone, PartialEq)]
317#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
318pub struct Setpoint {
319    /// The asset this is aimed at.
320    pub asset: AssetId,
321    /// What to do.
322    pub command: Command,
323    /// Why.
324    pub reason: Reason,
325    /// When the decision was made.
326    #[cfg_attr(feature = "serde", serde(with = "time::serde::rfc3339"))]
327    pub at: OffsetDateTime,
328}
329
330impl Setpoint {
331    /// Build a setpoint, refusing a command that carries a non-finite number.
332    ///
333    /// This is the one place hems checks for `NaN`: quantities are constructed
334    /// cheaply and infallibly everywhere else, and the gate sits where a number
335    /// turns into an action on a physical device.
336    ///
337    /// # Errors
338    /// [`SetpointError::NotFinite`] when the command contains `NaN` or infinity.
339    pub fn new(
340        asset: AssetId,
341        command: Command,
342        reason: Reason,
343        at: OffsetDateTime,
344    ) -> Result<Self, SetpointError> {
345        if !command.is_finite() {
346            return Err(SetpointError::NotFinite {
347                asset: asset.to_string(),
348            });
349        }
350        Ok(Self {
351            asset,
352            command,
353            reason,
354            at,
355        })
356    }
357
358    /// The authority behind this setpoint.
359    #[must_use]
360    pub const fn authority(&self) -> Authority {
361        self.reason.authority()
362    }
363}
364
365impl fmt::Display for Setpoint {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        write!(f, "{}: {} — {}", self.asset, self.command, self.reason)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use time::macros::datetime;
375
376    const NOW: OffsetDateTime = datetime!(2026-05-01 12:00:00 UTC);
377
378    #[test]
379    fn a_non_finite_command_never_becomes_a_setpoint() {
380        let asset = AssetId::new("wallbox").unwrap();
381        let err = Setpoint::new(
382            asset.clone(),
383            Command::ActivePower(Power::new_const(f64::NAN)),
384            Reason::guard(GuardRule::Lpc),
385            NOW,
386        )
387        .unwrap_err();
388        assert_eq!(
389            err,
390            SetpointError::NotFinite {
391                asset: "wallbox".into()
392            }
393        );
394    }
395
396    #[test]
397    fn guard_outranks_every_other_reason() {
398        let guard = Reason::guard(GuardRule::Lpc).authority();
399        for other in [
400            Reason::User(UserOverride::Boost).authority(),
401            Reason::Plan {
402                plan: PlanId::new(),
403                slot: Slot::containing(NOW),
404                marginal_eur_per_kwh: None,
405            }
406            .authority(),
407            Reason::Realtime(RealtimeCause::SurplusTracking).authority(),
408            Reason::Fallback(FallbackCause::NoPlan).authority(),
409        ] {
410            assert!(guard > other, "guard must outrank {other:?}");
411        }
412    }
413
414    #[test]
415    fn a_setpoint_explains_itself() {
416        let sp = Setpoint::new(
417            AssetId::new("wallbox").unwrap(),
418            Command::ConsumptionCeiling(Power::from_kw(4.2)),
419            Reason::guard_since(GuardRule::Lpc, NOW),
420            NOW,
421        )
422        .unwrap();
423        let rendered = sp.to_string();
424        assert!(rendered.contains("wallbox"), "{rendered}");
425        assert!(rendered.contains("consume at most"), "{rendered}");
426        assert!(rendered.contains("§ 14a"), "{rendered}");
427    }
428}