Skip to main content

frequenz_microgrid/
bounds.rs

1// License: MIT
2// Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
4//! A representation of Bounds for any metric.
5
6use crate::client::proto::common::metrics::Bounds as PbBounds;
7use crate::quantity::{Current, Power, Quantity, ReactivePower};
8
9/// A set of lower and upper bounds for any metric.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Bounds<Q: Quantity> {
12    /// The lower bound.
13    /// If None, there is no lower bound.
14    lower: Option<Q>,
15    /// The upper bound.
16    /// If None, there is no upper bound.
17    upper: Option<Q>,
18}
19
20impl<Q: Quantity> std::fmt::Display for Bounds<Q> {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        f.write_fmt(format_args!(
23            "[{}, {}]",
24            self.lower
25                .map_or_else(|| String::from("None"), |x| x.to_string()),
26            self.upper
27                .map_or_else(|| String::from("None"), |x| x.to_string()),
28        ))
29    }
30}
31
32impl<Q: Quantity> Bounds<Q> {
33    /// Creates a new `Bounds` with the given lower and upper bounds.
34    pub fn new(lower: Option<Q>, upper: Option<Q>) -> Self {
35        Self { lower, upper }
36    }
37
38    /// Returns the lower bound.
39    pub fn lower(&self) -> Option<Q> {
40        self.lower
41    }
42
43    /// Returns the upper bound.
44    pub fn upper(&self) -> Option<Q> {
45        self.upper
46    }
47
48    /// Combines two bounds as if their components were connected in parallel.
49    pub fn combine_parallel(&self, other: &Self) -> Vec<Self> {
50        if self.intersect(other).is_none() {
51            return vec![self.clone(), other.clone()];
52        }
53        // Lower side: if both lowers are ≤ 0, the components can both
54        // discharge, so the combined floor is the sum (more negative).
55        // Otherwise at least one range sits entirely above zero and the
56        // combined floor is just the lower of the two individual floors.
57        let lower = self.lower.and_then(|a| {
58            other.lower.map(|b| {
59                if a <= Q::zero() && b <= Q::zero() {
60                    a + b
61                } else {
62                    a.min(b)
63                }
64            })
65        });
66        // Upper side: mirror of the above — both ≥ 0 means both can charge and
67        // contributions add; otherwise take the higher of the two.
68        let upper = self.upper.and_then(|a| {
69            other.upper.map(|b| {
70                if a >= Q::zero() && b >= Q::zero() {
71                    a + b
72                } else {
73                    a.max(b)
74                }
75            })
76        });
77        vec![Bounds { lower, upper }]
78    }
79
80    /// Returns the intersection of `self` and `other`, or `None` if the
81    /// intersection is empty.
82    pub fn intersect(&self, other: &Self) -> Option<Self> {
83        let lower = Self::map_or_any(Q::max, self.lower, other.lower);
84        let upper = Self::map_or_any(Q::min, self.upper, other.upper);
85        if let (Some(lower), Some(upper)) = (lower, upper)
86            && lower > upper
87        {
88            return None;
89        }
90        Some(Bounds { lower, upper })
91    }
92
93    /// If `self` and `other` overlap, returns the smallest single interval
94    /// that contains both; otherwise returns `None`.
95    pub fn merge_if_overlapping(&self, other: &Self) -> Option<Self> {
96        self.intersect(other)?;
97        Some(Bounds {
98            lower: self.lower.and_then(|a| other.lower.map(|b| a.min(b))),
99            upper: self.upper.and_then(|a| other.upper.map(|b| a.max(b))),
100        })
101    }
102
103    /// Combines two `Option<Q>` values with `f`, treating `None` as the
104    /// identity: if exactly one side is `Some`, that value is returned
105    /// unchanged. Only `(None, None)` yields `None`.
106    fn map_or_any(f: impl FnOnce(Q, Q) -> Q, a: Option<Q>, b: Option<Q>) -> Option<Q> {
107        match (a, b) {
108            (Some(a), Some(b)) => Some(f(a, b)),
109            (Some(a), None) | (None, Some(a)) => Some(a),
110            (None, None) => None,
111        }
112    }
113}
114
115impl<Q: Quantity> From<(Option<Q>, Option<Q>)> for Bounds<Q> {
116    fn from(bounds: (Option<Q>, Option<Q>)) -> Self {
117        Self::new(bounds.0, bounds.1)
118    }
119}
120
121impl From<Bounds<Power>> for PbBounds {
122    fn from(bounds: Bounds<Power>) -> Self {
123        PbBounds {
124            lower: bounds.lower.map(|q| q.as_watts()),
125            upper: bounds.upper.map(|q| q.as_watts()),
126        }
127    }
128}
129
130impl From<Bounds<Current>> for PbBounds {
131    fn from(bounds: Bounds<Current>) -> Self {
132        PbBounds {
133            lower: bounds.lower.map(|q| q.as_amperes()),
134            upper: bounds.upper.map(|q| q.as_amperes()),
135        }
136    }
137}
138
139impl From<Bounds<ReactivePower>> for PbBounds {
140    fn from(bounds: Bounds<ReactivePower>) -> Self {
141        PbBounds {
142            lower: bounds.lower.map(|q| q.as_volt_amperes_reactive()),
143            upper: bounds.upper.map(|q| q.as_volt_amperes_reactive()),
144        }
145    }
146}
147
148impl From<PbBounds> for Bounds<Power> {
149    fn from(pb_bounds: PbBounds) -> Self {
150        Self::new(
151            pb_bounds.lower.map(Power::from_watts),
152            pb_bounds.upper.map(Power::from_watts),
153        )
154    }
155}
156
157impl From<PbBounds> for Bounds<Current> {
158    fn from(pb_bounds: PbBounds) -> Self {
159        Self::new(
160            pb_bounds.lower.map(Current::from_amperes),
161            pb_bounds.upper.map(Current::from_amperes),
162        )
163    }
164}
165
166impl From<PbBounds> for Bounds<ReactivePower> {
167    fn from(pb_bounds: PbBounds) -> Self {
168        Self::new(
169            pb_bounds
170                .lower
171                .map(ReactivePower::from_volt_amperes_reactive),
172            pb_bounds
173                .upper
174                .map(ReactivePower::from_volt_amperes_reactive),
175        )
176    }
177}
178
179/// Combines two sets of bounds from components connected in parallel.
180pub(crate) fn combine_parallel_sets<Q: Quantity>(
181    a: &[Bounds<Q>],
182    b: &[Bounds<Q>],
183) -> Vec<Bounds<Q>> {
184    match (a, b) {
185        (a, []) | ([], a) => a.to_vec(),
186        (a, b) => {
187            let mut result = Vec::new();
188            for b1 in a {
189                for b2 in b {
190                    result.extend(b1.combine_parallel(b2));
191                }
192            }
193            squash_bounds_sets(result)
194        }
195    }
196}
197
198/// Intersects two sets of bounds together, returning the intersection of the
199/// given sets.
200///
201/// This is used for calculating the combined bounds of two components connected
202/// in series.
203pub(crate) fn intersect_bounds_sets<Q: Quantity>(
204    a: &[Bounds<Q>],
205    b: &[Bounds<Q>],
206) -> Vec<Bounds<Q>> {
207    let mut result = Vec::new();
208    for b1 in a {
209        for b2 in b {
210            if let Some(int) = b1.intersect(b2) {
211                result.push(int);
212            }
213        }
214    }
215    squash_bounds_sets(result)
216}
217
218/// Merges overlapping bounds into disjoint intervals.
219fn squash_bounds_sets<Q: Quantity>(mut input: Vec<Bounds<Q>>) -> Vec<Bounds<Q>> {
220    if input.is_empty() {
221        return input;
222    }
223
224    input.sort_by(|a, b| {
225        a.lower
226            .unwrap_or(Q::MIN)
227            .partial_cmp(&b.lower.unwrap_or(Q::MIN))
228            .unwrap_or(std::cmp::Ordering::Equal)
229    });
230
231    let mut squashed = Vec::new();
232    let mut current = input[0].clone();
233
234    for next in &input[1..] {
235        if let Some(merged_bounds) = current.merge_if_overlapping(next) {
236            current = merged_bounds;
237        } else {
238            squashed.push(current);
239            current = next.clone();
240        }
241    }
242    squashed.push(current);
243
244    squashed
245}
246
247#[cfg(test)]
248mod tests {
249    use super::{Bounds, combine_parallel_sets, intersect_bounds_sets};
250    use crate::quantity::Power;
251
252    #[test]
253    fn test_bounds_addition() {
254        let b1 = Bounds::new(Some(-5.0), Some(5.0));
255        let b2 = Bounds::new(Some(-3.0), Some(3.0));
256        assert_eq!(
257            b1.combine_parallel(&b2),
258            vec![Bounds::new(Some(-8.0), Some(8.0))]
259        );
260
261        let b1 = Bounds::new(Some(-15.0), Some(-5.0));
262        let b2 = Bounds::new(Some(-10.0), Some(-2.0));
263        assert_eq!(
264            b1.combine_parallel(&b2),
265            vec![Bounds::new(Some(-25.0), Some(-2.0))]
266        );
267
268        let b1 = Bounds::new(Some(5.0), Some(15.0));
269        let b2 = Bounds::new(Some(2.0), Some(10.0));
270        assert_eq!(
271            b1.combine_parallel(&b2),
272            vec![Bounds::new(Some(2.0), Some(25.0))]
273        );
274
275        let b1 = Bounds::new(Some(5.0), Some(15.0));
276        let b2 = Bounds::new(None, Some(10.0));
277        assert_eq!(
278            b1.combine_parallel(&b2),
279            vec![Bounds::new(None, Some(25.0))]
280        );
281
282        let b1 = Bounds::new(Some(5.0), Some(15.0));
283        let b2 = Bounds::new(Some(-5.0), None);
284        assert_eq!(
285            b1.combine_parallel(&b2),
286            vec![Bounds::new(Some(-5.0), None)]
287        );
288
289        let b1 = Bounds::new(Some(5.0), Some(15.0));
290        let b2 = Bounds::new(None, None);
291        assert_eq!(b1.combine_parallel(&b2), vec![Bounds::new(None, None)]);
292
293        let b1 = Bounds::new(Some(-10.0), Some(-5.0));
294        let b2 = Bounds::new(Some(5.0), Some(15.0));
295        assert_eq!(b1.combine_parallel(&b2), vec![b1, b2]);
296    }
297
298    #[test]
299    fn test_combine_parallel_sets() {
300        let b1 = vec![Bounds::new(Some(-5.0), Some(5.0))];
301        let b2 = vec![
302            Bounds::new(Some(-5.0), Some(-2.0)),
303            Bounds::new(Some(2.0), Some(5.0)),
304        ];
305        let result = combine_parallel_sets(&b1, &b2);
306        assert_eq!(result, vec![Bounds::new(Some(-10.0), Some(10.0))]);
307
308        let b1 = vec![Bounds::new(Some(-5.0), Some(-1.0))];
309        let b2 = vec![
310            Bounds::new(Some(-5.0), Some(-2.0)),
311            Bounds::new(Some(2.0), Some(5.0)),
312        ];
313        let result = combine_parallel_sets(&b1, &b2);
314        assert_eq!(
315            result,
316            vec![
317                Bounds::new(Some(-10.0), Some(-1.0)),
318                Bounds::new(Some(2.0), Some(5.0))
319            ]
320        );
321    }
322
323    #[test]
324    fn test_intersect_bounds_sets() {
325        let vb1 = vec![
326            Bounds::new(Some(-30.0), Some(-10.0)),
327            Bounds::new(Some(10.0), Some(30.0)),
328        ];
329        let vb2 = vec![
330            Bounds::new(Some(-20.0), Some(0.0)),
331            Bounds::new(Some(20.0), Some(40.0)),
332        ];
333        let intersection = intersect_bounds_sets(&vb1, &vb2);
334        assert_eq!(
335            intersection,
336            vec![
337                Bounds::new(Some(-20.0), Some(-10.0)),
338                Bounds::new(Some(20.0), Some(30.0)),
339            ]
340        );
341
342        let vb2 = vec![
343            Bounds::new(Some(-20.0), None),
344            Bounds::new(None, Some(40.0)),
345        ];
346        let intersection = intersect_bounds_sets(&vb1, &vb2);
347        assert_eq!(
348            intersection,
349            vec![
350                Bounds::new(Some(-30.0), Some(-10.0)),
351                Bounds::new(Some(10.0), Some(30.0)),
352            ]
353        );
354
355        let vb2 = vec![
356            Bounds::new(None, Some(-20.0)),
357            Bounds::new(Some(20.0), None),
358        ];
359        let intersection = intersect_bounds_sets(&vb1, &vb2);
360        assert_eq!(
361            intersection,
362            vec![
363                Bounds::new(Some(-30.0), Some(-20.0)),
364                Bounds::new(Some(20.0), Some(30.0)),
365            ]
366        );
367
368        let vb2 = vec![Bounds::new(Some(-25.0), Some(25.0))];
369        let intersection = intersect_bounds_sets(&vb1, &vb2);
370        assert_eq!(
371            intersection,
372            vec![
373                Bounds::new(Some(-25.0), Some(-10.0)),
374                Bounds::new(Some(10.0), Some(25.0)),
375            ]
376        );
377
378        let vb2 = vec![Bounds::new(Some(-5.0), Some(5.0))];
379        let intersection = intersect_bounds_sets(&vb1, &vb2);
380        assert_eq!(intersection, vec![]);
381    }
382
383    /// Bounds are closed intervals: intersecting at a shared endpoint yields a
384    /// degenerate single-point interval rather than an empty result.
385    #[test]
386    fn intersect_single_point_is_non_empty() {
387        let a = Bounds::new(Some(5.0), Some(10.0));
388        let b = Bounds::new(Some(10.0), Some(15.0));
389        assert_eq!(a.intersect(&b), Some(Bounds::new(Some(10.0), Some(10.0))));
390    }
391
392    /// Closed-interval semantics in `squash`: two intervals that touch at a
393    /// single endpoint merge into one.
394    #[test]
395    fn squash_merges_touching_endpoints() {
396        let a = [Bounds::new(Some(1.0), Some(5.0))];
397        let b = [Bounds::new(Some(5.0), Some(10.0))];
398        // `intersect_bounds_sets` runs the pairwise intersect through squash.
399        let result = intersect_bounds_sets(
400            &[Bounds::new(Some(0.0), Some(20.0))],
401            &a.iter().chain(b.iter()).cloned().collect::<Vec<_>>(),
402        );
403        assert_eq!(result, vec![Bounds::new(Some(1.0), Some(10.0))]);
404    }
405
406    /// Fully-unbounded inputs are preserved through `combine_parallel`:
407    /// `(−∞, ∞) ⊕ (−∞, ∞)` is still `(−∞, ∞)`, not empty.
408    #[test]
409    fn combine_parallel_preserves_fully_unbounded() {
410        let a = Bounds::<f32>::new(None, None);
411        let b = Bounds::<f32>::new(None, None);
412        assert_eq!(a.combine_parallel(&b), vec![Bounds::new(None, None)]);
413    }
414
415    #[test]
416    fn display_renders_both_bounds() {
417        let b = Bounds::new(Some(-5.0_f32), Some(5.0_f32));
418        assert_eq!(b.to_string(), "[-5, 5]");
419    }
420
421    #[test]
422    fn display_renders_missing_lower_as_none() {
423        let b = Bounds::new(None, Some(5.0_f32));
424        assert_eq!(b.to_string(), "[None, 5]");
425    }
426
427    #[test]
428    fn display_renders_missing_upper_as_none() {
429        let b = Bounds::new(Some(-5.0_f32), None);
430        assert_eq!(b.to_string(), "[-5, None]");
431    }
432
433    #[test]
434    fn display_renders_fully_unbounded_as_none_none() {
435        let b = Bounds::<f32>::new(None, None);
436        assert_eq!(b.to_string(), "[None, None]");
437    }
438
439    #[test]
440    fn display_uses_inner_quantity_formatting() {
441        let b = Bounds::new(
442            Some(Power::from_kilowatts(-1.0)),
443            Some(Power::from_kilowatts(2.0)),
444        );
445        assert_eq!(b.to_string(), "[-1 kW, 2 kW]");
446    }
447}