1use 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#[derive(Debug, Clone, Copy, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct GeoPoint {
15 pub latitude: f64,
17 pub longitude: f64,
19 #[cfg_attr(feature = "serde", serde(default))]
21 pub altitude_m: f64,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
27pub struct GridConnection {
28 #[cfg_attr(feature = "serde", serde(default))]
30 pub malo: Option<MaloId>,
31 #[cfg_attr(feature = "serde", serde(default))]
33 pub melo: Option<MeloId>,
34 #[cfg_attr(feature = "serde", serde(default))]
37 pub dso_code: Option<String>,
38 #[cfg_attr(feature = "serde", serde(default))]
45 pub netzbereich: Option<String>,
46 pub fuse_current: Current,
48 #[cfg_attr(feature = "serde", serde(default))]
53 pub contract_power: Option<Power>,
54}
55
56impl GridConnection {
57 #[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 #[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 #[must_use]
91 pub fn export_ceiling(&self) -> Power {
92 self.fuse_current.to_power_3p(crate::units::NOMINAL_VOLTAGE)
93 }
94}
95
96#[derive(Debug, Clone, PartialEq)]
98#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
99pub struct Site {
100 pub id: SiteId,
102 #[cfg_attr(feature = "serde", serde(default))]
104 pub label: String,
105 pub location: GeoPoint,
107 pub grid: GridConnection,
109 pub circuits: Circuits,
111 pub assets: Vec<Asset>,
113}
114
115impl Site {
116 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 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 #[must_use]
164 pub fn asset(&self, id: &AssetId) -> Option<&Asset> {
165 self.assets.iter().find(|a| a.id() == id)
166 }
167
168 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 #[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 let pv = Power::from_kw(-5.0);
287 let haus = Power::from_kw(1.0);
288 let wallbox = Power::from_kw(3.0);
289 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}