Skip to main content

hems_core/
site.rs

1//! One installation: the grid connection, the circuits, the assets.
2
3use metering::{MaloId, MeloId};
4
5use crate::asset::Asset;
6use crate::circuit::Circuits;
7use crate::error::SiteError;
8use crate::ids::{AssetId, SiteId};
9use crate::units::{Current, Power};
10
11/// Where the site is, for the solar geometry and the weather forecast.
12#[derive(Debug, Clone, Copy, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct GeoPoint {
15    /// Degrees north.
16    pub latitude: f64,
17    /// Degrees east.
18    pub longitude: f64,
19    /// Metres above sea level.
20    #[cfg_attr(feature = "serde", serde(default))]
21    pub altitude_m: f64,
22}
23
24/// The Netzanschlusspunkt — where the installation meets the public grid.
25#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct GridConnection {
28    /// The market location, when the site is registered in the German market.
29    #[cfg_attr(feature = "serde", serde(default))]
30    pub malo: Option<MaloId>,
31    /// The metering location.
32    #[cfg_attr(feature = "serde", serde(default))]
33    pub melo: Option<MeloId>,
34    /// The network operator's BDEW code number, as it appears on the § 14a
35    /// agreement and in the market communication.
36    #[cfg_attr(feature = "serde", serde(default))]
37    pub dso_code: Option<String>,
38    /// The Netzbereich the operator has assigned the connection to.
39    ///
40    /// `[BK6-22-300 A1 8.2.b]` requires the operator to tell the customer which
41    /// one it is, and `[A1 8.4]` requires a monthly machine-readable list of
42    /// control actions per area. Knowing the area is what lets the planner
43    /// anticipate where and when reductions cluster.
44    #[cfg_attr(feature = "serde", serde(default))]
45    pub netzbereich: Option<String>,
46    /// The main fuse rating per outer conductor.
47    pub fuse_current: Current,
48    /// The contractually agreed connection power, where one is agreed.
49    ///
50    /// This is the value a CEM reports as `ContractualConsumptionNominalMax`
51    /// in the EEBUS LPC use case (`[LPC-042]`).
52    #[cfg_attr(feature = "serde", serde(default))]
53    pub contract_power: Option<Power>,
54}
55
56impl GridConnection {
57    /// A connection with nothing but a fuse — enough to run a site.
58    #[must_use]
59    pub fn new(fuse_current: Current) -> Self {
60        Self {
61            malo: None,
62            melo: None,
63            dso_code: None,
64            netzbereich: None,
65            fuse_current,
66            contract_power: None,
67        }
68    }
69
70    /// The largest symmetric import the connection permits: the smaller of the
71    /// fuse rating and any contractual limit.
72    #[must_use]
73    pub fn import_ceiling(&self) -> Power {
74        let from_fuse = self.fuse_current.to_power_3p(crate::units::NOMINAL_VOLTAGE);
75        match self.contract_power {
76            Some(contract) => from_fuse.min(contract),
77            None => from_fuse,
78        }
79    }
80
81    /// The largest symmetric export the connection permits, as a positive
82    /// magnitude.
83    ///
84    /// The fuse alone. [`GridConnection::contract_power`] is deliberately not
85    /// applied here: it is the *ContractualConsumptionNominalMax* of `[LPC-042]`
86    /// — an agreement about how much the household may **draw** — and a system
87    /// whose feed-in is limited is limited by § 9 EEG, an LPP session or the
88    /// Einspeisezusage, none of which is this number. Applying a consumption
89    /// agreement to production would curtail a roof for a limit nobody wrote.
90    #[must_use]
91    pub fn export_ceiling(&self) -> Power {
92        self.fuse_current.to_power_3p(crate::units::NOMINAL_VOLTAGE)
93    }
94}
95
96/// One installation.
97#[derive(Debug, Clone, PartialEq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct Site {
100    /// Fleet-unique identity.
101    pub id: SiteId,
102    /// A human label.
103    #[cfg_attr(feature = "serde", serde(default))]
104    pub label: String,
105    /// Where it is.
106    pub location: GeoPoint,
107    /// The grid connection.
108    pub grid: GridConnection,
109    /// The electrical tree.
110    pub circuits: Circuits,
111    /// Everything behind the connection.
112    pub assets: Vec<Asset>,
113}
114
115impl Site {
116    /// Build a site and check that it is internally consistent.
117    ///
118    /// # Errors
119    /// [`SiteError`] for a duplicate asset name, an asset on an unknown circuit,
120    /// or a circuit tree that is not a tree.
121    pub fn new(
122        id: SiteId,
123        location: GeoPoint,
124        grid: GridConnection,
125        circuits: Circuits,
126        assets: Vec<Asset>,
127    ) -> Result<Self, SiteError> {
128        let site = Self {
129            id,
130            label: String::new(),
131            location,
132            grid,
133            circuits,
134            assets,
135        };
136        site.validate()?;
137        Ok(site)
138    }
139
140    /// Check the cross-references.
141    ///
142    /// # Errors
143    /// [`SiteError`] as described on [`Site::new`].
144    pub fn validate(&self) -> Result<(), SiteError> {
145        for (i, a) in self.assets.iter().enumerate() {
146            if self.assets[..i].iter().any(|o| o.id() == a.id()) {
147                return Err(SiteError::DuplicateId {
148                    kind: "asset",
149                    id: a.id().to_string(),
150                });
151            }
152            if self.circuits.get(&a.meta().circuit).is_none() {
153                return Err(SiteError::UnknownCircuit {
154                    asset: a.id().to_string(),
155                    circuit: a.meta().circuit.to_string(),
156                });
157            }
158        }
159        Ok(())
160    }
161
162    /// One asset by name.
163    #[must_use]
164    pub fn asset(&self, id: &AssetId) -> Option<&Asset> {
165        self.assets.iter().find(|a| a.id() == id)
166    }
167
168    /// The assets that are meters of the grid connection point.
169    pub fn grid_meters(&self) -> impl Iterator<Item = &Asset> {
170        self.assets.iter().filter(
171            |a| matches!(a, Asset::Meter(m) if m.role == crate::asset::MeterRole::GridConnection),
172        )
173    }
174
175    /// How far the measured grid power is from the sum of the measured assets.
176    ///
177    /// With the load convention of [`crate::units`], the site balance is
178    ///
179    /// ```text
180    /// grid == Σ assets
181    /// ```
182    ///
183    /// so a residual that is not near zero means a meter is missing, mis-signed
184    /// or stale. `hems-realtime` watches it, and a large residual makes the
185    /// arbiter fall back to conservative assumptions rather than optimise
186    /// against a fiction.
187    #[must_use]
188    pub fn balance_residual(grid: Power, assets: impl IntoIterator<Item = Power>) -> Power {
189        grid - assets.into_iter().sum::<Power>()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::asset::{AssetMeta, CapRelief, Capabilities, Evse, FlexibleLoad, LoadKind, PvArray};
197    use crate::circuit::Circuit;
198    use crate::ids::CircuitId;
199    use crate::units::PhaseConnection;
200
201    fn cid(s: &str) -> CircuitId {
202        CircuitId::new(s).unwrap()
203    }
204
205    fn meta(id: &str, circuit: &str, kw: f64) -> AssetMeta {
206        AssetMeta::new(
207            AssetId::new(id).unwrap(),
208            cid(circuit),
209            PhaseConnection::Three,
210            Power::from_kw(kw),
211        )
212        .with_capabilities(Capabilities::MEASURE)
213    }
214
215    fn site() -> Site {
216        Site::new(
217            SiteId::new(),
218            GeoPoint {
219                latitude: 52.52,
220                longitude: 13.40,
221                altitude_m: 34.0,
222            },
223            GridConnection::new(Current::new(35.0)),
224            Circuits::new(vec![
225                Circuit::new(cid("main"), None, Current::new(35.0)),
226                Circuit::new(cid("garage"), Some(cid("main")), Current::new(20.0)),
227            ])
228            .unwrap(),
229            vec![
230                Asset::Pv(PvArray {
231                    meta: meta("pv", "main", 9.8),
232                    kwp_dc: Power::from_kw(9.8),
233                    ac_nominal: Power::from_kw(8.0),
234                    tilt_deg: 35.0,
235                    azimuth_deg: 180.0,
236                    cap_relief: CapRelief::None,
237                }),
238                Asset::Evse(Evse {
239                    meta: meta("wallbox", "garage", 11.0),
240                    min_current: Current::new(6.0),
241                    max_current: Current::new(16.0),
242                    bidirectional: false,
243                    public: false,
244                }),
245                Asset::Load(FlexibleLoad {
246                    meta: meta("haushalt", "main", 3.0),
247                    nominal: Power::from_kw(0.5),
248                    kind: LoadKind::Fixed,
249                }),
250            ],
251        )
252        .unwrap()
253    }
254
255    #[test]
256    fn an_asset_on_an_unknown_circuit_is_refused() {
257        let mut s = site();
258        s.assets.push(Asset::Load(FlexibleLoad {
259            meta: meta("pool", "keller", 1.0),
260            nominal: Power::from_kw(1.0),
261            kind: LoadKind::Interruptible,
262        }));
263        assert!(matches!(
264            s.validate(),
265            Err(SiteError::UnknownCircuit { .. })
266        ));
267    }
268
269    #[test]
270    fn a_duplicate_asset_name_is_refused() {
271        let mut s = site();
272        s.assets.push(Asset::Load(FlexibleLoad {
273            meta: meta("pv", "main", 1.0),
274            nominal: Power::from_kw(1.0),
275            kind: LoadKind::Fixed,
276        }));
277        assert!(matches!(
278            s.validate(),
279            Err(SiteError::DuplicateId { kind: "asset", .. })
280        ));
281    }
282
283    #[test]
284    fn the_balance_closes_when_the_signs_are_right() {
285        // PV producing 5 kW, household drawing 1 kW, wallbox charging 3 kW.
286        let pv = Power::from_kw(-5.0);
287        let haus = Power::from_kw(1.0);
288        let wallbox = Power::from_kw(3.0);
289        // Net: 1 + 3 − 5 = −1 kW, i.e. exporting a kilowatt.
290        let grid = Power::from_kw(-1.0);
291        assert!(Site::balance_residual(grid, [pv, haus, wallbox]).abs() < Power::new(1.0));
292    }
293
294    #[test]
295    fn the_import_ceiling_takes_the_stricter_of_fuse_and_contract() {
296        let mut g = GridConnection::new(Current::new(63.0));
297        assert!((g.import_ceiling().kw() - 43.47).abs() < 0.01);
298        g.contract_power = Some(Power::from_kw(30.0));
299        assert_eq!(g.import_ceiling(), Power::from_kw(30.0));
300    }
301
302    #[test]
303    fn assets_below_a_circuit_are_found_through_the_tree() {
304        let s = site();
305        let below = s.circuits.assets_below(&cid("garage"), &s.assets);
306        assert_eq!(below.len(), 1);
307        assert_eq!(below[0].as_str(), "wallbox");
308        assert_eq!(s.circuits.assets_below(&cid("main"), &s.assets).len(), 3);
309    }
310}