hems_core/asset.rs
1//! The things behind the grid connection.
2//!
3//! Each variant carries the *facts* about a device — ratings, geometry,
4//! capabilities, when it was commissioned. It carries no rules: whether a heat
5//! pump is a steuerbare Verbrauchseinrichtung, and what minimum power it is
6//! owed, is decided by `hems-grid` from these facts. Keeping facts and rules
7//! apart is what lets the same site description outlive a change in the
8//! Festlegung.
9
10use time::Date;
11
12use crate::envelope::Envelope;
13use crate::ids::{AssetId, CircuitId};
14use crate::units::{Current, Energy, NOMINAL_VOLTAGE, PhaseConnection, PhaseMode, Power, Soc};
15
16/// What a driver can do with an asset.
17///
18/// A bitset rather than a set of `Option` fields, because the arbiter asks
19/// "can this be limited?" on every tick and the answer must be a branch, not an
20/// allocation.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(transparent))]
24pub struct Capabilities(u16);
25
26impl Capabilities {
27 /// Reports measurements.
28 pub const MEASURE: Self = Self(1 << 0);
29 /// Accepts a consumption ceiling.
30 pub const LIMIT_CONSUMPTION: Self = Self(1 << 1);
31 /// Accepts a production ceiling (curtailment).
32 pub const LIMIT_PRODUCTION: Self = Self(1 << 2);
33 /// Accepts an active-power target, both signs where the device allows it.
34 pub const SET_POWER: Self = Self(1 << 3);
35 /// Accepts a discrete operating mode (SG Ready, OMBC).
36 pub const SET_MODE: Self = Self(1 << 4);
37 /// Accepts a schedule for later execution.
38 pub const SCHEDULE: Self = Self(1 << 5);
39 /// Can export as well as import (bidirectional).
40 pub const BIDIRECTIONAL: Self = Self(1 << 7);
41 /// Can be identified physically (blink, beep) during commissioning.
42 pub const IDENTIFY: Self = Self(1 << 8);
43
44 // Note there is no `SWITCH_PHASES`. Whether a device can change its
45 // conductor count is [`PhaseConnection::Switchable`], and one fact with two
46 // representations is one fact that can contradict itself — a capability
47 // declared on a fixed three-phase connection is a charge point asked to
48 // switch that cannot.
49
50 /// No capabilities.
51 pub const NONE: Self = Self(0);
52
53 /// The union of two sets.
54 #[must_use]
55 pub const fn union(self, other: Self) -> Self {
56 Self(self.0 | other.0)
57 }
58
59 /// `true` when every capability in `other` is present.
60 #[must_use]
61 pub const fn contains(self, other: Self) -> bool {
62 self.0 & other.0 == other.0
63 }
64}
65
66impl core::ops::BitOr for Capabilities {
67 type Output = Self;
68 fn bitor(self, rhs: Self) -> Self {
69 self.union(rhs)
70 }
71}
72
73/// What a device brings with it from before 2024, `[BK6-22-300 A1 10]`.
74///
75/// A fact about the device and its contract, not a rule: `hems-grid` turns it
76/// into a regime. It has to live here because the guard needs it on every tick
77/// and the answer is not derivable from anything else on the asset — whether a
78/// reduced network fee was ever granted is in a contract, not in a datasheet.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
82pub enum LegacyStatus {
83 /// Nothing: no reduced network fee was ever granted for it.
84 #[default]
85 None,
86 /// A reduced network fee under the old § 14a Abs. 2 Satz 1 EnWG or its
87 /// predecessor was granted, `[A1 10.1]`.
88 ReducedNetworkFee,
89 /// It is a night-storage heater on the old rule, `[A1 10.2.b]`.
90 Nachtspeicher,
91}
92
93/// What has taken a photovoltaic system out of the 60 % feed-in cap of
94/// § 9 Abs. 2 EEG.
95///
96/// A fact about the installation, not a rule — `hems_grid::para9` turns it into
97/// a ceiling. It lives here for the same reason [`LegacyStatus`] does: the guard
98/// needs it on every tick, and the answer is in an installation record rather
99/// than in a datasheet.
100///
101/// The cap is lifted by a **technical fact**, not by a commercial arrangement,
102/// and the distinction is worth a type rather than a `direktvermarktung`
103/// boolean: a system whose market contract is signed would otherwise lose its
104/// cap whether or not the control path existed. Direktvermarktung *requires*
105/// Fernsteuerbarkeit (§ 10b EEG), so the two normally travel together — but "the
106/// contract is signed" and "the control path works" are different days, and on
107/// the days between them the cap still applies.
108///
109/// [`Default`]s to [`CapRelief::None`] on purpose: the cap staying on costs a
110/// household some feed-in, lifting it wrongly means feeding in above a statutory
111/// limit, and only one of those is the operator's problem.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
114#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
115pub enum CapRelief {
116 /// Nothing. The cap applies if the system is otherwise in scope.
117 #[default]
118 None,
119 /// An intelligent metering system with a control device is **in operation**
120 /// — the lift § 9 Abs. 2 EEG names.
121 ImsysWithControl,
122 /// The output is sold on the market and the Fernsteuerbarkeit § 10b EEG
123 /// demands is working. The commercial arrangement alone is not enough.
124 DirektvermarktungFernsteuerbar,
125}
126
127impl CapRelief {
128 /// Whether this lifts the cap.
129 #[must_use]
130 pub const fn lifts_cap(self) -> bool {
131 !matches!(self, CapRelief::None)
132 }
133}
134
135/// Why an asset that looks like a steuerbare Verbrauchseinrichtung is not one.
136///
137/// `[BK6-22-300 Anlage 1 Ziff. 3.1.b]`. The list is closed: anything not on it
138/// participates.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
142pub enum SteuVeExemption {
143 /// A publicly accessible charge point (§ 2 Nr. 5 LSV) — outside the scope of
144 /// Ziff. 2.4.1.a from the start.
145 PublicChargePoint,
146 /// Operated by an institution with Sonderrechte under § 35 Abs. 1, 5a StVO
147 /// — fire service, ambulance, police `[A1 3.1.b aa]`.
148 EmergencyServices,
149 /// Heating or cooling that does not serve living, office or common rooms —
150 /// process heat, and equipment serving critical infrastructure
151 /// `[A1 3.1.b bb]`.
152 NonResidentialHeatingOrCooling,
153}
154
155/// Facts every asset carries.
156#[derive(Debug, Clone, PartialEq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158pub struct AssetMeta {
159 /// The name used in configuration, topics and the UI.
160 pub id: AssetId,
161 /// A human label for the UI. Falls back to the id when empty.
162 #[cfg_attr(feature = "serde", serde(default))]
163 pub label: String,
164 /// The circuit this asset hangs off.
165 pub circuit: CircuitId,
166 /// How it is connected to the outer conductors.
167 pub phases: PhaseConnection,
168 /// Netzanschlussleistung — the nameplate power the network operator sees.
169 ///
170 /// This is the number the 4,2 kW threshold and the 0,4 scaling factor of
171 /// `[BK6-22-300 A1 2.4.1 / 4.5.1]` are applied to, so it is a declared
172 /// value, not a measured one.
173 pub connection_power: Power,
174 /// The date of technical commissioning.
175 ///
176 /// `[A1 3.1.b]` makes participation in the netzorientierte Steuerung
177 /// mandatory for devices commissioned **after 31.12.2023**; `[A1 10]` puts
178 /// everything older into one of the transitional regimes. An unknown date
179 /// leaves the device **in** the § 14a group: dropping a device out of the
180 /// group is how a site exceeds a network operator's limit, and it also
181 /// lowers the minimum power the customer is owed. See
182 /// `hems_grid::para14a::participation`.
183 #[cfg_attr(feature = "serde", serde(default))]
184 pub commissioned_at: Option<Date>,
185 /// Why this asset does not participate, if it does not.
186 #[cfg_attr(feature = "serde", serde(default))]
187 pub steuve_exemption: Option<SteuVeExemption>,
188 /// What the device brings with it from before 2024, `[A1 10]`.
189 #[cfg_attr(feature = "serde", serde(default))]
190 pub legacy_status: LegacyStatus,
191 /// Whether the operator moved this device into the netzorientierte Steuerung
192 /// voluntarily, `[A1 10.4]`.
193 ///
194 /// The network operator may not refuse and there is no way back, so this is
195 /// a contractual fact that is stored rather than derived.
196 #[cfg_attr(feature = "serde", serde(default))]
197 pub switched_voluntarily: bool,
198 /// What the driver can do with it.
199 pub capabilities: Capabilities,
200}
201
202impl AssetMeta {
203 /// A minimal description: identity, circuit, phases and rating.
204 #[must_use]
205 pub fn new(
206 id: AssetId,
207 circuit: CircuitId,
208 phases: PhaseConnection,
209 connection_power: Power,
210 ) -> Self {
211 Self {
212 label: id.to_string(),
213 id,
214 circuit,
215 phases,
216 connection_power,
217 commissioned_at: None,
218 steuve_exemption: None,
219 legacy_status: LegacyStatus::None,
220 switched_voluntarily: false,
221 capabilities: Capabilities::MEASURE,
222 }
223 }
224
225 /// Replace the capability set.
226 #[must_use]
227 pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
228 self.capabilities = capabilities;
229 self
230 }
231
232 /// Set the commissioning date.
233 #[must_use]
234 pub fn commissioned(mut self, date: Date) -> Self {
235 self.commissioned_at = Some(date);
236 self
237 }
238
239 /// Declare an exemption from § 14a participation.
240 #[must_use]
241 pub fn exempt(mut self, reason: SteuVeExemption) -> Self {
242 self.steuve_exemption = Some(reason);
243 self
244 }
245
246 /// Record what the device brings with it from before 2024, `[A1 10]`.
247 #[must_use]
248 pub fn with_legacy_status(mut self, legacy_status: LegacyStatus) -> Self {
249 self.legacy_status = legacy_status;
250 self
251 }
252
253 /// Record the irreversible move into the netzorientierte Steuerung,
254 /// `[A1 10.4]`.
255 #[must_use]
256 pub fn switched_voluntarily(mut self) -> Self {
257 self.switched_voluntarily = true;
258 self
259 }
260}
261
262/// Which Fallgruppe of `[BK6-22-300 A1 2.4.1]` an asset belongs to.
263///
264/// This is `metering`'s type, not a copy of it. The grouping matters — heat
265/// pumps and cooling are each summed **per Fallgruppe** behind one connection
266/// before the 4,2 kW threshold is applied `[A1 2.4.2]`, charge points and
267/// storage are not — and `metering::para14a` is where the arithmetic that
268/// depends on it lives. Two enumerations of the same four Fallgruppen are two
269/// things that can disagree about a regulation, which is the one kind of
270/// duplication this workspace cannot afford.
271pub use metering::para14a::SteuVeFallgruppe as Fallgruppe;
272
273/// How a heat pump can be told what to do.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
276#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
277pub enum HeatPumpControl {
278 /// Two relay contacts carrying the four SG Ready states.
279 ///
280 /// Coarse — the states are "blocked", "normal", "recommended on",
281 /// "commanded on", not a power value — and from 1 July 2027 no longer enough
282 /// for a BEG-funded heat pump, which needs an interoperable digital
283 /// interface in a Code-of-Conduct format.
284 SgReady,
285 /// A continuous electrical power ceiling (EEBUS LPC, or a vendor register).
286 PowerCeiling,
287 /// Discrete modes over a digital interface (EEBUS OMBC-style).
288 OperationModes,
289}
290
291/// A photovoltaic array with its inverter.
292#[derive(Debug, Clone, PartialEq)]
293#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
294pub struct PvArray {
295 /// Identity and connection.
296 pub meta: AssetMeta,
297 /// Installed DC power in watts — the reference the § 9 EEG 60 % cap and the
298 /// EEBUS `MGCP` feed-in factor are percentages *of*.
299 pub kwp_dc: Power,
300 /// The inverter's AC limit.
301 pub ac_nominal: Power,
302 /// Module tilt from horizontal, degrees.
303 #[cfg_attr(feature = "serde", serde(default))]
304 pub tilt_deg: f64,
305 /// Module azimuth, degrees east of north (180 = due south).
306 #[cfg_attr(feature = "serde", serde(default))]
307 pub azimuth_deg: f64,
308 /// What, if anything, has lifted the § 9 Abs. 2 EEG 60 % feed-in cap for
309 /// this system.
310 ///
311 /// Together with [`AssetMeta::commissioned_at`] and [`PvArray::kwp_dc`] this
312 /// is everything `hems_grid::para9` needs to decide whether the cap applies.
313 #[cfg_attr(feature = "serde", serde(default))]
314 pub cap_relief: CapRelief,
315}
316
317/// Battery chemistry, because degradation behaves differently.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
319#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
320#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
321pub enum Chemistry {
322 /// Lithium iron phosphate — cheap cycles, the home-storage default.
323 #[default]
324 Lfp,
325 /// Nickel manganese cobalt — denser, markedly more cycle-sensitive.
326 Nmc,
327 /// Anything else; the optimiser uses conservative defaults.
328 Other,
329}
330
331/// A stationary battery.
332#[derive(Debug, Clone, PartialEq)]
333#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
334pub struct Battery {
335 /// Identity and connection.
336 pub meta: AssetMeta,
337 /// Usable capacity.
338 pub capacity: Energy,
339 /// Maximum charging power.
340 pub max_charge: Power,
341 /// Maximum discharging power, as a positive magnitude.
342 pub max_discharge: Power,
343 /// One-way charging efficiency in `(0, 1]`.
344 pub efficiency_charge: f64,
345 /// One-way discharging efficiency in `(0, 1]`.
346 pub efficiency_discharge: f64,
347 /// Lowest state of charge the system will use in normal operation.
348 pub soc_min: Soc,
349 /// Highest state of charge the system will use.
350 pub soc_max: Soc,
351 /// Energy held back for a power cut. Neither planner nor arbiter may plan
352 /// below it; only an islanded system may use it.
353 #[cfg_attr(feature = "serde", serde(default))]
354 pub reserve_soc: Soc,
355 /// Cell chemistry.
356 #[cfg_attr(feature = "serde", serde(default))]
357 pub chemistry: Chemistry,
358 /// Whether the system can charge from the grid at all. A storage system that
359 /// cannot is outside MiSpeL entirely and always "green".
360 #[cfg_attr(feature = "serde", serde(default))]
361 pub grid_charging_allowed: bool,
362}
363
364/// A charge point for electric vehicles.
365#[derive(Debug, Clone, PartialEq)]
366#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
367pub struct Evse {
368 /// Identity and connection.
369 pub meta: AssetMeta,
370 /// Lowest current the standard allows a session to run at — 6 A in
371 /// IEC 61851, which is why a "small" limit still means 1,4 kW single-phase.
372 pub min_current: Current,
373 /// Highest current the hardware allows.
374 pub max_current: Current,
375 /// Whether the charge point can discharge the vehicle (V2H/V2G).
376 #[cfg_attr(feature = "serde", serde(default))]
377 pub bidirectional: bool,
378 /// Whether it is publicly accessible in the sense of § 2 Nr. 5 LSV.
379 #[cfg_attr(feature = "serde", serde(default))]
380 pub public: bool,
381}
382
383/// A heat pump, with whatever auxiliary heater is bound to it.
384#[derive(Debug, Clone, PartialEq)]
385#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
386pub struct HeatPump {
387 /// Identity and connection.
388 pub meta: AssetMeta,
389 /// Nominal electrical input power of the compressor.
390 pub electrical_nominal: Power,
391 /// Electrical power of the auxiliary or emergency heater, if fitted.
392 ///
393 /// `[A1 2.4.1.b]` folds it into the same Fallgruppe as the compressor, so it
394 /// belongs to this asset rather than being a load of its own.
395 #[cfg_attr(feature = "serde", serde(default))]
396 pub heating_rod: Option<Power>,
397 /// How it takes commands.
398 pub control: HeatPumpControl,
399 /// Whether the pump modulates or only starts and stops.
400 #[cfg_attr(feature = "serde", serde(default))]
401 pub modulating: bool,
402}
403
404impl Evse {
405 /// The number of outer conductors a session uses in `mode`.
406 #[must_use]
407 pub fn phase_count(&self, mode: PhaseMode) -> u8 {
408 self.meta.phases.count(mode).max(1)
409 }
410
411 /// The least power at which a session runs at all, in `mode`.
412 ///
413 /// IEC 61851 puts the floor at 6 A per conductor. Below it a charge point is
414 /// not charging slowly, it is idle — which is why allocating it 2 kW wastes
415 /// the 2 kW rather than charging the car slowly.
416 ///
417 /// The mode is the whole point: 6 A on three conductors is 4,1 kW, and on
418 /// one it is 1,4 kW. A household with 2 kW of surplus can charge in the
419 /// second and not in the first, and that gap is why a switchable charge
420 /// point is worth the contactor.
421 #[must_use]
422 pub fn min_power(&self, mode: PhaseMode) -> Power {
423 Power::new(
424 self.min_current.get() * NOMINAL_VOLTAGE.get() * f64::from(self.phase_count(mode)),
425 )
426 }
427
428 /// The most a session can draw in `mode`.
429 #[must_use]
430 pub fn max_power(&self, mode: PhaseMode) -> Power {
431 Power::new(
432 self.max_current.get() * NOMINAL_VOLTAGE.get() * f64::from(self.phase_count(mode)),
433 )
434 .min(self.meta.connection_power.abs())
435 }
436
437 /// The smallest power at which this charge point could charge in **any**
438 /// mode it is wired for.
439 ///
440 /// What an allocator needs: switching a device off because it cannot run on
441 /// 2 kW three-phase, when it could run on 2 kW single-phase, wastes the
442 /// 2 kW *and* the switch the hardware came with.
443 #[must_use]
444 pub fn lowest_useful_power(&self) -> Power {
445 [PhaseMode::Single, PhaseMode::Three]
446 .into_iter()
447 .filter(|m| self.meta.phases.supports(*m))
448 .map(|m| self.min_power(m))
449 .reduce(Power::min)
450 .unwrap_or_else(|| self.min_power(self.meta.phases.default_mode()))
451 }
452
453 /// The largest power at which it could charge in any mode it is wired for.
454 #[must_use]
455 pub fn highest_power(&self) -> Power {
456 [PhaseMode::Single, PhaseMode::Three]
457 .into_iter()
458 .filter(|m| self.meta.phases.supports(*m))
459 .map(|m| self.max_power(m))
460 .reduce(Power::max)
461 .unwrap_or_else(|| self.max_power(self.meta.phases.default_mode()))
462 }
463}
464
465impl Battery {
466 /// The lowest state of charge normal operation may reach: the operating
467 /// floor, or the backup reserve where that is higher.
468 ///
469 /// A reserve is a promise to the household — "there will be something left
470 /// when the street goes dark" — so it binds the guard, not only the planner.
471 #[must_use]
472 pub fn discharge_floor(&self) -> Soc {
473 if self.reserve_soc > self.soc_min {
474 self.reserve_soc
475 } else {
476 self.soc_min
477 }
478 }
479}
480
481impl HeatPump {
482 /// Compressor plus auxiliary heater — the Fallgruppe's summed power.
483 #[must_use]
484 pub fn group_power(&self) -> Power {
485 self.electrical_nominal + self.heating_rod.unwrap_or(Power::ZERO)
486 }
487}
488
489/// Specific heat of water, kWh per litre and kelvin.
490///
491/// 4,186 kJ/(kg·K) at one kilogram per litre, in the unit the rest of this
492/// workspace counts energy in.
493pub const WATER_KWH_PER_LITRE_KELVIN: f64 = 4.186 / 3600.0;
494
495/// A domestic hot water tank with an electric heater.
496///
497/// The cheapest store in most German houses and the one nobody plans with. Three
498/// hundred litres between 45 and 60 °C hold about 5 kWh of heat; a hot-water heat
499/// pump puts it there at a coefficient of performance around three, so shifting
500/// a day's washing into the sunny hours is worth a couple of kilowatt-hours of
501/// import at the retail price for a device that costs nothing to control.
502///
503/// It is deliberately **not** a steuerbare Verbrauchseinrichtung. `[A1 2.4.1]`
504/// lists exactly four Fallgruppen — charge point, heat-pump heating including
505/// its auxiliary heaters, space cooling, and storage while charging — and a
506/// water heater is in none of them. A Heizstab that is the *heat pump's*
507/// Zusatzheizung is another matter and is already counted there, through
508/// [`HeatPump::heating_rod`]. So a § 14a reduction does not bind a tank, while
509/// the fuse above it and the connection behind it still do, and its consumption
510/// still spends the surplus that would otherwise have raised the § 14a budget.
511#[derive(Debug, Clone, PartialEq)]
512#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
513pub struct DhwTank {
514 /// Identity and connection.
515 pub meta: AssetMeta,
516 /// Tank volume in litres.
517 pub volume_l: f64,
518 /// Electrical heating power.
519 pub heater: Power,
520 /// Thermal kilowatt-hours delivered per electrical kilowatt-hour.
521 ///
522 /// One for an immersion heater, around three for a hot-water heat pump. It
523 /// is what decides whether the tank is worth planning with at all.
524 #[cfg_attr(feature = "serde", serde(default = "one"))]
525 pub cop: f64,
526 /// Standing loss — the reason a tank left alone is cold in the morning.
527 #[cfg_attr(feature = "serde", serde(default))]
528 pub standing_loss: Power,
529 /// Lowest acceptable temperature, °C.
530 pub t_min_c: f64,
531 /// Target temperature, °C.
532 pub t_set_c: f64,
533 /// Highest safe temperature, °C.
534 pub t_max_c: f64,
535}
536
537fn one() -> f64 {
538 1.0
539}
540
541impl DhwTank {
542 /// Heat capacity of the water in the tank, kWh per kelvin.
543 #[must_use]
544 pub fn heat_capacity_kwh_per_k(&self) -> f64 {
545 self.volume_l.max(0.0) * WATER_KWH_PER_LITRE_KELVIN
546 }
547
548 /// The heat the tank may hold between its lowest acceptable and its highest
549 /// safe temperature — the store the planner is allowed to move.
550 #[must_use]
551 pub fn usable_heat(&self) -> Energy {
552 Energy::from_kwh(self.heat_capacity_kwh_per_k() * (self.t_max_c - self.t_min_c).max(0.0))
553 }
554
555 /// The heat stored at `temperature_c`, measured from the lowest acceptable
556 /// temperature and clamped to what the tank can hold.
557 #[must_use]
558 pub fn stored_heat(&self, temperature_c: f64) -> Energy {
559 let above = (temperature_c - self.t_min_c).max(0.0);
560 Energy::from_kwh(self.heat_capacity_kwh_per_k() * above).min(self.usable_heat())
561 }
562
563 /// The temperature `stored` corresponds to, °C — the number to show a
564 /// household, which does not think in kilowatt-hours of water.
565 #[must_use]
566 pub fn temperature_at(&self, stored: Energy) -> f64 {
567 let c = self.heat_capacity_kwh_per_k();
568 if c <= 0.0 {
569 return self.t_min_c;
570 }
571 self.t_min_c + stored.kwh() / c
572 }
573}
574
575/// How much freedom a load gives the planner.
576#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
577#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
578#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
579pub enum LoadKind {
580 /// Cannot be influenced. The household base load.
581 #[default]
582 Fixed,
583 /// Can be started later, but not interrupted once running — a dishwasher.
584 Shiftable,
585 /// Can be interrupted and resumed — a pool pump, a dehumidifier.
586 Interruptible,
587}
588
589/// A load other than the modelled appliances.
590#[derive(Debug, Clone, PartialEq)]
591#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
592pub struct FlexibleLoad {
593 /// Identity and connection.
594 pub meta: AssetMeta,
595 /// Nominal power while running.
596 pub nominal: Power,
597 /// How much freedom the planner has.
598 pub kind: LoadKind,
599}
600
601/// What a meter measures.
602#[derive(Debug, Clone, Copy, PartialEq, Eq)]
603#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
604#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
605pub enum MeterRole {
606 /// The grid connection point — the meter every decision starts from.
607 GridConnection,
608 /// Production of one generator.
609 Production,
610 /// A sub-meter for one asset, named by `subject`.
611 Submeter,
612 /// Total household consumption.
613 Consumption,
614}
615
616/// A meter.
617#[derive(Debug, Clone, PartialEq)]
618#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
619pub struct Meter {
620 /// Identity and connection.
621 pub meta: AssetMeta,
622 /// What it measures.
623 pub role: MeterRole,
624 /// The asset this meter is attached to, for a sub-meter.
625 #[cfg_attr(feature = "serde", serde(default))]
626 pub subject: Option<AssetId>,
627}
628
629/// A switched output — an SG Ready contact, a heating-rod contactor.
630#[derive(Debug, Clone, PartialEq)]
631#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
632pub struct Relay {
633 /// Identity and connection.
634 pub meta: AssetMeta,
635 /// What closing the contact does, for the UI and the evidence record.
636 pub purpose: String,
637}
638
639/// Anything that consumes, produces, stores or measures.
640#[derive(Debug, Clone, PartialEq)]
641#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
642#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "type"))]
643pub enum Asset {
644 /// A photovoltaic array.
645 Pv(PvArray),
646 /// A stationary battery.
647 Battery(Battery),
648 /// A charge point.
649 Evse(Evse),
650 /// A heat pump.
651 HeatPump(HeatPump),
652 /// A hot water tank.
653 Dhw(DhwTank),
654 /// Any other load.
655 Load(FlexibleLoad),
656 /// A meter.
657 Meter(Meter),
658 /// A switched output.
659 Relay(Relay),
660}
661
662impl Asset {
663 /// The facts common to every asset.
664 #[must_use]
665 pub fn meta(&self) -> &AssetMeta {
666 match self {
667 Asset::Pv(a) => &a.meta,
668 Asset::Battery(a) => &a.meta,
669 Asset::Evse(a) => &a.meta,
670 Asset::HeatPump(a) => &a.meta,
671 Asset::Dhw(a) => &a.meta,
672 Asset::Load(a) => &a.meta,
673 Asset::Meter(a) => &a.meta,
674 Asset::Relay(a) => &a.meta,
675 }
676 }
677
678 /// The identifier.
679 #[must_use]
680 pub fn id(&self) -> &AssetId {
681 &self.meta().id
682 }
683
684 /// What the driver can do with it.
685 #[must_use]
686 pub fn capabilities(&self) -> Capabilities {
687 self.meta().capabilities
688 }
689
690 /// Which § 14a Fallgruppe the asset falls into, ignoring thresholds,
691 /// exemptions and commissioning dates — those are `hems-grid`'s decision.
692 ///
693 /// A hot water tank's heater is *not* a Fallgruppe of its own: `[A1 2.4.1.b]`
694 /// counts auxiliary and emergency heaters with the heat pump, and a tank
695 /// without a heat pump behind it is an ordinary load.
696 #[must_use]
697 pub fn fallgruppe(&self) -> Option<Fallgruppe> {
698 match self {
699 Asset::Evse(e) if !e.public => Some(Fallgruppe::Ladepunkt),
700 Asset::HeatPump(_) => Some(Fallgruppe::Waermepumpe),
701 Asset::Battery(_) => Some(Fallgruppe::Stromspeicher),
702 _ => None,
703 }
704 }
705
706 /// Whether VDE-AR-N 4100's symmetry requirement applies to this device.
707 ///
708 /// Not to everything behind the connection, which is the reading that looks
709 /// obvious and is wrong. The VDE FNN Hinweis *Symmetrischer Anschluss und
710 /// Betrieb in Kundenanlagen* is explicit about the scope of Abschnitt
711 /// 5.5.2: *"Die Anforderungen zum symmetrischen Betrieb gelten nur für
712 /// Geräte die elektrische Energie einspeisen oder speichern können, also
713 /// Erzeugungsanlagen, Speicher, Ladeeinrichtungen für Elektrofahrzeuge."*
714 ///
715 /// So an inverter, a battery and a charge point are in scope; a heat pump,
716 /// a hot-water tank and the household's own single-phase load are not.
717 /// Counting them made the site look more unbalanced than the rule says it
718 /// is, and spent the difference on the one device the manager could still
719 /// move.
720 #[must_use]
721 pub fn symmetry_relevant(&self) -> bool {
722 matches!(self, Asset::Pv(_) | Asset::Battery(_) | Asset::Evse(_))
723 }
724
725 /// The power the § 14a threshold is applied to.
726 ///
727 /// For a heat pump this is compressor **plus** heating rod `[A1 2.4.1.b]`.
728 #[must_use]
729 pub fn steuve_power(&self) -> Power {
730 match self {
731 Asset::HeatPump(hp) => hp.group_power(),
732 other => other.meta().connection_power,
733 }
734 }
735
736 /// What the hardware itself can do, before any rule applies.
737 ///
738 /// The nameplate `connection_power` is what the network operator sees, and
739 /// it is symmetric — which is wrong for almost every asset. A photovoltaic
740 /// array cannot consume, a charge point without bidirectional hardware
741 /// cannot export, and a battery's charging and discharging ratings are
742 /// routinely different. Handing the guard a symmetric interval invites it to
743 /// command a value the device will silently ignore, and an ignored command
744 /// is indistinguishable from a driver fault in the log.
745 #[must_use]
746 pub fn ratings(&self) -> Envelope {
747 match self {
748 // An inverter produces; the load convention makes that negative. The
749 // small standby draw at night is not worth modelling as a floor.
750 Asset::Pv(pv) => Envelope::new(-pv.ac_nominal.max(Power::ZERO), Power::ZERO),
751 Asset::Battery(b) => {
752 Envelope::new(-b.max_discharge.abs(), b.max_charge.max(Power::ZERO))
753 }
754 Asset::Evse(e) => {
755 let ceiling = e.highest_power();
756 Envelope::new(
757 if e.bidirectional {
758 -ceiling
759 } else {
760 Power::ZERO
761 },
762 ceiling,
763 )
764 }
765 Asset::HeatPump(hp) => Envelope::new(Power::ZERO, hp.group_power()),
766 Asset::Dhw(t) => Envelope::new(Power::ZERO, t.heater.max(Power::ZERO)),
767 // A load consumes. A symmetric envelope would make a dishwasher
768 // look like a generator, and the guard would offer it a share of the
769 // connection's export capacity and of the § 9 EEG cap — capacity a
770 // device that cannot produce a watt then holds against the inverter
771 // that can.
772 Asset::Load(l) => Envelope::new(Power::ZERO, l.meta.connection_power.max(Power::ZERO)),
773 Asset::Relay(r) => Envelope::new(Power::ZERO, r.meta.connection_power.max(Power::ZERO)),
774 // A meter is never commanded; its envelope exists only so that the
775 // map has an entry for every asset, and it is symmetric because a
776 // meter sees current in both directions.
777 Asset::Meter(m) => {
778 let p = m.meta.connection_power.abs();
779 Envelope::new(-p, p)
780 }
781 }
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use crate::units::Phase;
789
790 fn meta(id: &str, kw: f64) -> AssetMeta {
791 AssetMeta::new(
792 AssetId::new(id).unwrap(),
793 CircuitId::new("main").unwrap(),
794 PhaseConnection::Three,
795 Power::from_kw(kw),
796 )
797 }
798
799 #[test]
800 fn a_heat_pumps_steuve_power_includes_its_heating_rod() {
801 let hp = HeatPump {
802 meta: meta("wp", 5.0),
803 electrical_nominal: Power::from_kw(5.0),
804 heating_rod: Some(Power::from_kw(6.0)),
805 control: HeatPumpControl::PowerCeiling,
806 modulating: true,
807 };
808 // 5 + 6 = 11 kW: exactly the threshold above which the 0,4 scaling of
809 // [A1 4.5.1] applies, so getting this sum wrong changes the minimum power.
810 assert_eq!(Asset::HeatPump(hp).steuve_power(), Power::from_kw(11.0));
811 }
812
813 #[test]
814 fn a_public_charge_point_is_not_a_fallgruppe() {
815 let mk = |public| {
816 Asset::Evse(Evse {
817 meta: meta("wallbox", 11.0),
818 min_current: Current::new(6.0),
819 max_current: Current::new(16.0),
820 bidirectional: false,
821 public,
822 })
823 };
824 assert_eq!(mk(false).fallgruppe(), Some(Fallgruppe::Ladepunkt));
825 assert_eq!(mk(true).fallgruppe(), None);
826 }
827
828 #[test]
829 fn ratings_are_asymmetric_because_hardware_is() {
830 let pv = Asset::Pv(PvArray {
831 meta: meta("pv", 9.8),
832 kwp_dc: Power::from_kw(9.8),
833 ac_nominal: Power::from_kw(8.0),
834 tilt_deg: 35.0,
835 azimuth_deg: 180.0,
836 cap_relief: CapRelief::None,
837 });
838 // An inverter cannot consume, so its ceiling is zero, not +8 kW.
839 assert_eq!(pv.ratings().ceiling, Power::ZERO);
840 assert_eq!(pv.ratings().floor, Power::from_kw(-8.0));
841
842 let wallbox = Asset::Evse(Evse {
843 meta: meta("wallbox", 11.0),
844 min_current: Current::new(6.0),
845 max_current: Current::new(16.0),
846 bidirectional: false,
847 public: false,
848 });
849 // A one-way charge point cannot export whatever the nameplate says.
850 assert_eq!(wallbox.ratings().floor, Power::ZERO);
851 assert!((wallbox.ratings().ceiling.kw() - 11.0).abs() < 1e-9);
852 }
853
854 #[test]
855 fn a_batterys_backup_reserve_beats_its_operating_floor() {
856 let b = Battery {
857 meta: meta("battery", 5.0),
858 capacity: Energy::from_kwh(10.0),
859 max_charge: Power::from_kw(5.0),
860 max_discharge: Power::from_kw(4.0),
861 efficiency_charge: 0.95,
862 efficiency_discharge: 0.95,
863 soc_min: Soc::new(0.05).unwrap(),
864 soc_max: Soc::new(0.95).unwrap(),
865 reserve_soc: Soc::new(0.30).unwrap(),
866 chemistry: Chemistry::Lfp,
867 grid_charging_allowed: true,
868 };
869 assert_eq!(b.discharge_floor(), Soc::new(0.30).unwrap());
870 let ratings = Asset::Battery(b).ratings();
871 assert_eq!(ratings.floor, Power::from_kw(-4.0));
872 assert_eq!(ratings.ceiling, Power::from_kw(5.0));
873 }
874
875 #[test]
876 fn capabilities_compose_and_answer_questions() {
877 let caps = Capabilities::MEASURE | Capabilities::LIMIT_CONSUMPTION | Capabilities::IDENTIFY;
878 assert!(caps.contains(Capabilities::LIMIT_CONSUMPTION));
879 assert!(!caps.contains(Capabilities::BIDIRECTIONAL));
880 assert!(caps.contains(Capabilities::MEASURE | Capabilities::IDENTIFY));
881 }
882
883 #[test]
884 fn a_single_phase_asset_names_its_conductor() {
885 let m = AssetMeta::new(
886 AssetId::new("heizstab").unwrap(),
887 CircuitId::new("main").unwrap(),
888 PhaseConnection::Single { phase: Phase::L2 },
889 Power::from_kw(3.0),
890 );
891 assert_eq!(m.phases.count(PhaseMode::Three), 1, "it cannot be switched");
892 assert_eq!(m.phases.single_phase_conductor(), Some(Phase::L2));
893 }
894
895 #[test]
896 fn a_switchable_charge_point_has_two_minimums_and_the_lower_one_matters() {
897 let mut m = meta("wallbox", 11.0);
898 m.phases = PhaseConnection::Switchable { phase: Phase::L1 };
899 let e = Evse {
900 meta: m,
901 min_current: Current::new(6.0),
902 max_current: Current::new(16.0),
903 bidirectional: false,
904 public: false,
905 };
906 // 6 A × 230 V × 3 = 4,14 kW three-phase; a third of it on one conductor.
907 assert!((e.min_power(PhaseMode::Three).kw() - 4.14).abs() < 1e-9);
908 assert!((e.min_power(PhaseMode::Single).kw() - 1.38).abs() < 1e-9);
909 assert_eq!(e.lowest_useful_power(), e.min_power(PhaseMode::Single));
910 assert_eq!(e.highest_power(), e.max_power(PhaseMode::Three));
911
912 // A fixed three-phase charge point has only the one.
913 let mut m = meta("fixed", 11.0);
914 m.phases = PhaseConnection::Three;
915 let fixed = Evse { meta: m, ..e };
916 assert_eq!(
917 fixed.lowest_useful_power(),
918 fixed.min_power(PhaseMode::Three)
919 );
920 }
921}