Skip to main content

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}