Skip to main content

hems_core/
circuit.rs

1//! The electrical tree between the grid connection and each asset.
2//!
3//! A house is not a single busbar. A wallbox in the garage sits behind a
4//! sub-distribution board with its own cable and its own fuse, and the sum of
5//! everything behind that board is bounded by it — independently of, and
6//! usually well below, the main connection. Load management that only knows the
7//! main fuse either trips the sub-board or leaves capacity unused.
8//!
9//! Circuits form a tree rooted at the grid connection. The arbiter narrows every
10//! asset's feasible interval by every limit on its path to the root, which is
11//! the same mechanism the § 14a and § 9 EEG limits use — they are simply limits
12//! that sit at the root.
13
14use crate::error::SiteError;
15use crate::ids::{AssetId, CircuitId};
16use crate::units::{Current, Power};
17
18/// One node of the electrical tree.
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct Circuit {
22    /// The name used in configuration.
23    pub id: CircuitId,
24    /// The circuit this one hangs off. `None` for the root.
25    #[cfg_attr(feature = "serde", serde(default))]
26    pub parent: Option<CircuitId>,
27    /// A human label for the UI.
28    #[cfg_attr(feature = "serde", serde(default))]
29    pub label: String,
30    /// The fuse rating per outer conductor.
31    #[cfg_attr(feature = "serde", serde(default))]
32    pub fuse_current: Option<Current>,
33    /// A power ceiling that is not simply the fuse — a cable rating, or a limit
34    /// the operator wants for their own reasons.
35    #[cfg_attr(feature = "serde", serde(default))]
36    pub power_limit: Option<Power>,
37}
38
39impl Circuit {
40    /// A circuit with a fuse rating.
41    #[must_use]
42    pub fn new(id: CircuitId, parent: Option<CircuitId>, fuse_current: Current) -> Self {
43        Self {
44            label: id.to_string(),
45            id,
46            parent,
47            fuse_current: Some(fuse_current),
48            power_limit: None,
49        }
50    }
51
52    /// The tightest power ceiling this circuit imposes on a symmetric
53    /// three-phase draw, given the nominal voltage.
54    ///
55    /// A single-phase asset is bounded by the *per-phase* current instead; the
56    /// arbiter uses [`Circuit::fuse_current`] directly for that case, because
57    /// collapsing a per-phase limit into a total is exactly the mistake that
58    /// lets one conductor overload while the total looks fine.
59    #[must_use]
60    pub fn symmetric_power_limit(&self, voltage: crate::units::Voltage) -> Option<Power> {
61        let from_fuse = self.fuse_current.map(|i| i.to_power_3p(voltage));
62        match (from_fuse, self.power_limit) {
63            (Some(a), Some(b)) => Some(a.min(b)),
64            (a, b) => a.or(b),
65        }
66    }
67}
68
69/// The circuit tree of one site, with the lookups the arbiter needs.
70#[derive(Debug, Clone, PartialEq, Default)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
72#[cfg_attr(feature = "serde", serde(transparent))]
73pub struct Circuits {
74    circuits: Vec<Circuit>,
75}
76
77impl Circuits {
78    /// Build from a list, checking that the result is a tree.
79    ///
80    /// # Errors
81    /// [`SiteError::DuplicateId`] for a repeated name,
82    /// [`SiteError::UnknownParent`] for a dangling parent,
83    /// [`SiteError::CircuitCycle`] when the parent links form a cycle.
84    pub fn new(circuits: Vec<Circuit>) -> Result<Self, SiteError> {
85        for (i, c) in circuits.iter().enumerate() {
86            if circuits[..i].iter().any(|o| o.id == c.id) {
87                return Err(SiteError::DuplicateId {
88                    kind: "circuit",
89                    id: c.id.to_string(),
90                });
91            }
92        }
93        for c in &circuits {
94            if let Some(parent) = &c.parent
95                && !circuits.iter().any(|o| &o.id == parent)
96            {
97                return Err(SiteError::UnknownParent {
98                    circuit: c.id.to_string(),
99                    parent: parent.to_string(),
100                });
101            }
102        }
103        let this = Self { circuits };
104        // Walk upwards from every node; a tree of n nodes has no path longer
105        // than n, so exceeding that means the links close a cycle.
106        for c in &this.circuits {
107            let mut cursor = c;
108            for _ in 0..=this.circuits.len() {
109                match cursor.parent.as_ref().and_then(|p| this.get(p)) {
110                    Some(parent) => cursor = parent,
111                    None => break,
112                }
113                if cursor.id == c.id {
114                    return Err(SiteError::CircuitCycle {
115                        circuit: c.id.to_string(),
116                    });
117                }
118            }
119        }
120        Ok(this)
121    }
122
123    /// One circuit by name.
124    #[must_use]
125    pub fn get(&self, id: &CircuitId) -> Option<&Circuit> {
126        self.circuits.iter().find(|c| &c.id == id)
127    }
128
129    /// Every circuit.
130    #[must_use]
131    pub fn all(&self) -> &[Circuit] {
132        &self.circuits
133    }
134
135    /// The circuits from `id` up to the root, innermost first.
136    ///
137    /// Every limit on this path binds the asset, so the arbiter intersects them.
138    #[must_use]
139    pub fn path_to_root(&self, id: &CircuitId) -> Vec<&Circuit> {
140        let mut path = Vec::new();
141        let mut cursor = self.get(id);
142        // The constructor rules out cycles, so this terminates; the bound is
143        // belt and braces for a `Circuits` built by hand in a test.
144        for _ in 0..=self.circuits.len() {
145            let Some(c) = cursor else { break };
146            path.push(c);
147            cursor = c.parent.as_ref().and_then(|p| self.get(p));
148        }
149        path
150    }
151
152    /// The circuits that lie between `asset`'s circuit and the root.
153    #[must_use]
154    pub fn path_for_asset(&self, asset: &crate::asset::Asset) -> Vec<&Circuit> {
155        self.path_to_root(&asset.meta().circuit)
156    }
157
158    /// The assets, out of `assets`, that hang off `circuit` or anything below it.
159    #[must_use]
160    pub fn assets_below<'a>(
161        &self,
162        circuit: &CircuitId,
163        assets: &'a [crate::asset::Asset],
164    ) -> Vec<&'a AssetId> {
165        assets
166            .iter()
167            .filter(|a| {
168                self.path_to_root(&a.meta().circuit)
169                    .iter()
170                    .any(|c| &c.id == circuit)
171            })
172            .map(crate::asset::Asset::id)
173            .collect()
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn cid(s: &str) -> CircuitId {
182        CircuitId::new(s).unwrap()
183    }
184
185    fn tree() -> Circuits {
186        Circuits::new(vec![
187            Circuit::new(cid("main"), None, Current::new(63.0)),
188            Circuit::new(cid("garage"), Some(cid("main")), Current::new(20.0)),
189        ])
190        .unwrap()
191    }
192
193    #[test]
194    fn a_path_collects_every_limit_up_to_the_root() {
195        let ids: Vec<_> = tree()
196            .path_to_root(&cid("garage"))
197            .iter()
198            .map(|c| c.id.to_string())
199            .collect();
200        assert_eq!(ids, ["garage", "main"]);
201    }
202
203    #[test]
204    fn a_dangling_parent_is_refused() {
205        let err = Circuits::new(vec![Circuit::new(
206            cid("garage"),
207            Some(cid("nope")),
208            Current::new(20.0),
209        )])
210        .unwrap_err();
211        assert!(matches!(err, SiteError::UnknownParent { .. }));
212    }
213
214    #[test]
215    fn a_cycle_is_refused_rather_than_hanging_the_arbiter() {
216        let err = Circuits::new(vec![
217            Circuit::new(cid("a"), Some(cid("b")), Current::new(20.0)),
218            Circuit::new(cid("b"), Some(cid("a")), Current::new(20.0)),
219        ])
220        .unwrap_err();
221        assert!(matches!(err, SiteError::CircuitCycle { .. }));
222    }
223
224    #[test]
225    fn a_duplicate_name_is_refused() {
226        let err = Circuits::new(vec![
227            Circuit::new(cid("main"), None, Current::new(63.0)),
228            Circuit::new(cid("main"), None, Current::new(35.0)),
229        ])
230        .unwrap_err();
231        assert!(matches!(
232            err,
233            SiteError::DuplicateId {
234                kind: "circuit",
235                ..
236            }
237        ));
238    }
239
240    #[test]
241    fn a_fuse_becomes_a_symmetric_power_ceiling() {
242        let c = Circuit::new(cid("garage"), None, Current::new(20.0));
243        // 3 × 20 A × 230 V = 13,8 kW
244        let limit = c
245            .symmetric_power_limit(crate::units::NOMINAL_VOLTAGE)
246            .unwrap();
247        assert!((limit.kw() - 13.8).abs() < 1e-9);
248    }
249}