Skip to main content

hems_core/
envelope.rs

1//! The interval of power an asset may currently use.
2//!
3//! Every layer of hems narrows an interval rather than picking a number:
4//! the guard narrows it for grid and safety reasons, the plan narrows it to what
5//! it wants, the arbiter picks a point inside what is left. The idea is
6//! OpenEMS's — a scheduler that hands each controller "an interval of possible
7//! solutions" that later controllers can only shrink — made explicit, so that
8//! "the grid limit was respected" is an intersection, not a code path someone
9//! has to remember to write.
10
11use core::fmt;
12
13use crate::units::Power;
14
15/// A closed interval of active power, load convention.
16///
17/// `floor` may be negative (the asset may be required to feed in or discharge)
18/// and `ceiling` may be positive. An empty interval — `floor > ceiling` — is a
19/// real and meaningful outcome: two rules that cannot both be satisfied. It is
20/// resolved by [`Envelope::resolve`], which keeps the stricter ceiling, because
21/// exceeding a grid limit is worse than falling short of a floor.
22#[derive(Debug, Clone, Copy, PartialEq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct Envelope {
25    /// The lowest permitted power.
26    pub floor: Power,
27    /// The highest permitted power.
28    pub ceiling: Power,
29}
30
31impl Envelope {
32    /// Everything a device could physically do, before any rule applies.
33    pub const UNBOUNDED: Self = Self {
34        floor: Power::new_const(f64::NEG_INFINITY),
35        ceiling: Power::new_const(f64::INFINITY),
36    };
37
38    /// An interval.
39    #[must_use]
40    pub const fn new(floor: Power, ceiling: Power) -> Self {
41        Self { floor, ceiling }
42    }
43
44    /// Only an upper bound — what a grid limit is.
45    #[must_use]
46    pub const fn at_most(ceiling: Power) -> Self {
47        Self {
48            floor: Power::new_const(f64::NEG_INFINITY),
49            ceiling,
50        }
51    }
52
53    /// Only a lower bound.
54    #[must_use]
55    pub const fn at_least(floor: Power) -> Self {
56        Self {
57            floor,
58            ceiling: Power::new_const(f64::INFINITY),
59        }
60    }
61
62    /// Exactly one value.
63    #[must_use]
64    pub const fn exactly(value: Power) -> Self {
65        Self {
66            floor: value,
67            ceiling: value,
68        }
69    }
70
71    /// The intersection of two intervals — the operation the whole design rests
72    /// on. Narrowing is the only thing any layer is allowed to do.
73    #[must_use]
74    pub fn intersect(self, other: Self) -> Self {
75        Self {
76            floor: self.floor.max(other.floor),
77            ceiling: self.ceiling.min(other.ceiling),
78        }
79    }
80
81    /// `true` when no value satisfies both bounds.
82    #[must_use]
83    pub fn is_empty(self) -> bool {
84        self.floor > self.ceiling
85    }
86
87    /// `true` when `value` lies inside.
88    #[must_use]
89    pub fn contains(self, value: Power) -> bool {
90        value >= self.floor && value <= self.ceiling
91    }
92
93    /// The value inside the interval closest to `wanted`.
94    ///
95    /// When the interval is empty the ceiling wins: a grid or safety ceiling is
96    /// a duty towards someone else, a floor is a comfort promise to the
97    /// household, and breaking the second is recoverable.
98    #[must_use]
99    pub fn clamp(self, wanted: Power) -> Power {
100        if self.is_empty() {
101            return self.ceiling;
102        }
103        wanted.max(self.floor).min(self.ceiling)
104    }
105
106    /// The interval with an empty one collapsed onto its ceiling, so downstream
107    /// code never has to ask again.
108    #[must_use]
109    pub fn resolve(self) -> Self {
110        if self.is_empty() {
111            Self::exactly(self.ceiling)
112        } else {
113            self
114        }
115    }
116
117    /// How much room there is between the bounds, or zero when empty.
118    #[must_use]
119    pub fn width(self) -> Power {
120        if self.is_empty() {
121            Power::ZERO
122        } else {
123            self.ceiling - self.floor
124        }
125    }
126}
127
128impl fmt::Display for Envelope {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match (
131            self.floor.get().is_infinite(),
132            self.ceiling.get().is_infinite(),
133        ) {
134            (true, true) => f.write_str("unbounded"),
135            (true, false) => write!(f, "≤ {}", self.ceiling),
136            (false, true) => write!(f, "≥ {}", self.floor),
137            (false, false) => write!(f, "{} … {}", self.floor, self.ceiling),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn intersection_only_ever_narrows() {
148        let a = Envelope::new(Power::from_kw(0.0), Power::from_kw(11.0));
149        let b = Envelope::at_most(Power::from_kw(4.2));
150        let n = a.intersect(b);
151        assert_eq!(n.ceiling, Power::from_kw(4.2));
152        assert_eq!(n.floor, Power::ZERO);
153        assert!(n.width() <= a.width());
154    }
155
156    #[test]
157    fn an_unsatisfiable_pair_resolves_towards_the_ceiling() {
158        // A wallbox that cannot go below 6 A (1,4 kW) meeting a 1 kW grid limit.
159        let device = Envelope::at_least(Power::from_kw(1.4));
160        let grid = Envelope::at_most(Power::from_kw(1.0));
161        let both = device.intersect(grid);
162        assert!(both.is_empty());
163        assert_eq!(
164            both.clamp(Power::from_kw(5.0)),
165            Power::from_kw(1.0),
166            "the grid limit wins"
167        );
168        assert_eq!(both.resolve(), Envelope::exactly(Power::from_kw(1.0)));
169    }
170
171    #[test]
172    fn clamping_keeps_a_wanted_value_that_already_fits() {
173        let e = Envelope::new(Power::ZERO, Power::from_kw(11.0));
174        assert_eq!(e.clamp(Power::from_kw(4.0)), Power::from_kw(4.0));
175        assert_eq!(e.clamp(Power::from_kw(20.0)), Power::from_kw(11.0));
176        assert_eq!(e.clamp(Power::from_kw(-5.0)), Power::ZERO);
177    }
178
179    #[test]
180    fn unbounded_is_the_identity_of_intersection() {
181        let e = Envelope::new(Power::from_kw(-5.0), Power::from_kw(5.0));
182        assert_eq!(e.intersect(Envelope::UNBOUNDED), e);
183    }
184}