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}