Skip to main content

hems_flex/
map.rs

1//! Which control type an asset belongs to.
2//!
3//! The choice is not arbitrary and it is not per-device-class either: it follows
4//! from **what the energy manager needs to be able to say** to the thing.
5//!
6//! A charge point with no departure time is a power envelope — tell it a bound
7//! and it charges. The same charge point with a car that must be full by seven
8//! is *storage*: it has a fill level, a rate and a target, and describing it as
9//! an envelope throws all three away. So the mapping depends on the situation,
10//! not only on the hardware, which is exactly the distinction S2 draws and
11//! use-case-organised protocols cannot.
12
13use hems_core::prelude::*;
14use s2energy::common::{ControlType as S2ControlType, RoleType};
15
16/// The S2 control types, as hems uses them.
17///
18/// A thin mirror of [`s2energy::common::ControlType`] so that the mapping can be
19/// matched on and tested without constructing wire messages.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum ControlType {
22    /// Fill Rate Based Control — a store with a level and a rate.
23    Frbc,
24    /// Power Envelope Based Control — a bound is all that is needed.
25    Pebc,
26    /// Operation Mode Based Control — discrete states.
27    Ombc,
28    /// Power Profile Based Control — a fixed sequence started in a window.
29    Ppbc,
30    /// Demand Driven Based Control — actuators serving a reported demand.
31    Ddbc,
32    /// The device takes no instruction at all; it can only be measured.
33    NotControllable,
34}
35
36impl From<ControlType> for S2ControlType {
37    fn from(value: ControlType) -> Self {
38        match value {
39            ControlType::Frbc => S2ControlType::FillRateBasedControl,
40            ControlType::Pebc => S2ControlType::PowerEnvelopeBasedControl,
41            ControlType::Ombc => S2ControlType::OperationModeBasedControl,
42            ControlType::Ppbc => S2ControlType::PowerProfileBasedControl,
43            ControlType::Ddbc => S2ControlType::DemandDrivenBasedControl,
44            ControlType::NotControllable => S2ControlType::NotControlable,
45        }
46    }
47}
48
49/// The control type that best describes `asset`.
50///
51/// `has_deadline` says whether a charge point currently has a car with a
52/// departure time — the one case where the same hardware is described
53/// differently, and the reason this takes an argument at all.
54#[must_use]
55pub fn control_type_for(asset: &Asset, has_deadline: bool) -> ControlType {
56    match asset {
57        // A store, whatever it stores. Fill level, rate, and a range to stay in.
58        Asset::Battery(_) | Asset::Dhw(_) => ControlType::Frbc,
59
60        // With a departure time the car is a store with a target; without one,
61        // a bound is all the manager can usefully say.
62        Asset::Evse(_) => {
63            if has_deadline {
64                ControlType::Frbc
65            } else {
66                ControlType::Pebc
67            }
68        }
69
70        // Three contacts are three operation modes. A heat pump that takes a
71        // power ceiling is an envelope; one that heats a buffer the manager can
72        // see is really storage, but S2 wants the *store* described by whoever
73        // owns it, and a heat pump rarely exposes its buffer.
74        Asset::HeatPump(hp) => match hp.control {
75            HeatPumpControl::SgReady | HeatPumpControl::OperationModes => ControlType::Ombc,
76            HeatPumpControl::PowerCeiling => ControlType::Pebc,
77        },
78
79        // Curtailment is a ceiling and nothing else.
80        Asset::Pv(_) => ControlType::Pebc,
81
82        Asset::Load(load) => match load.kind {
83            // A washing machine runs a programme; it can be started later but
84            // not turned down, which is precisely PPBC.
85            LoadKind::Shiftable => ControlType::Ppbc,
86            LoadKind::Interruptible => ControlType::Ombc,
87            LoadKind::Fixed => ControlType::NotControllable,
88        },
89
90        Asset::Relay(_) => ControlType::Ombc,
91        Asset::Meter(_) => ControlType::NotControllable,
92    }
93}
94
95/// The energy roles an asset plays, as S2 reports them.
96///
97/// A battery is both a consumer and a producer *and* a store — S2 lets a
98/// Resource Manager declare several, and a manager that assumes one will plan a
99/// battery as a load.
100#[must_use]
101pub fn roles_for(asset: &Asset) -> Vec<RoleType> {
102    match asset {
103        Asset::Battery(_) => {
104            vec![
105                RoleType::EnergyStorage,
106                RoleType::EnergyConsumer,
107                RoleType::EnergyProducer,
108            ]
109        }
110        Asset::Evse(evse) => {
111            if evse.bidirectional {
112                vec![
113                    RoleType::EnergyStorage,
114                    RoleType::EnergyConsumer,
115                    RoleType::EnergyProducer,
116                ]
117            } else {
118                vec![RoleType::EnergyConsumer]
119            }
120        }
121        Asset::Pv(_) => vec![RoleType::EnergyProducer],
122        Asset::Dhw(_) => vec![RoleType::EnergyStorage, RoleType::EnergyConsumer],
123        Asset::HeatPump(_) | Asset::Load(_) | Asset::Relay(_) => vec![RoleType::EnergyConsumer],
124        Asset::Meter(_) => Vec::new(),
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use hems_core::asset::{AssetMeta, Battery, Chemistry, Evse, FlexibleLoad, HeatPump, PvArray};
132
133    fn meta(id: &str, kw: f64) -> AssetMeta {
134        AssetMeta::new(
135            AssetId::new(id).unwrap(),
136            CircuitId::new("main").unwrap(),
137            PhaseConnection::Three,
138            Power::from_kw(kw),
139        )
140    }
141
142    fn evse(bidirectional: bool) -> Asset {
143        Asset::Evse(Evse {
144            meta: meta("wallbox", 11.0),
145            min_current: Current::new(6.0),
146            max_current: Current::new(16.0),
147            bidirectional,
148            public: false,
149        })
150    }
151
152    fn battery() -> Asset {
153        Asset::Battery(Battery {
154            meta: meta("battery", 5.0),
155            capacity: Energy::from_kwh(10.0),
156            max_charge: Power::from_kw(5.0),
157            max_discharge: Power::from_kw(5.0),
158            efficiency_charge: 0.95,
159            efficiency_discharge: 0.95,
160            soc_min: Soc::new(0.05).unwrap(),
161            soc_max: Soc::FULL,
162            reserve_soc: Soc::EMPTY,
163            chemistry: Chemistry::Lfp,
164            grid_charging_allowed: true,
165        })
166    }
167
168    #[test]
169    fn a_battery_is_a_store() {
170        assert_eq!(control_type_for(&battery(), false), ControlType::Frbc);
171    }
172
173    #[test]
174    fn a_charge_point_changes_control_type_when_the_car_has_a_deadline() {
175        // The point of the whole mapping: the same hardware, described
176        // differently because what the manager needs to say has changed.
177        assert_eq!(control_type_for(&evse(false), false), ControlType::Pebc);
178        assert_eq!(control_type_for(&evse(false), true), ControlType::Frbc);
179    }
180
181    #[test]
182    fn an_sg_ready_heat_pump_is_modes_and_a_modern_one_is_an_envelope() {
183        let hp = |control| {
184            Asset::HeatPump(HeatPump {
185                meta: meta("wp", 9.0),
186                electrical_nominal: Power::from_kw(5.0),
187                heating_rod: None,
188                control,
189                modulating: true,
190            })
191        };
192        assert_eq!(
193            control_type_for(&hp(HeatPumpControl::SgReady), false),
194            ControlType::Ombc
195        );
196        assert_eq!(
197            control_type_for(&hp(HeatPumpControl::PowerCeiling), false),
198            ControlType::Pebc
199        );
200    }
201
202    #[test]
203    fn a_washing_machine_is_a_profile_and_a_pool_pump_is_modes() {
204        let load = |kind| {
205            Asset::Load(FlexibleLoad {
206                meta: meta("geraet", 2.0),
207                nominal: Power::from_kw(2.0),
208                kind,
209            })
210        };
211        assert_eq!(
212            control_type_for(&load(LoadKind::Shiftable), false),
213            ControlType::Ppbc
214        );
215        assert_eq!(
216            control_type_for(&load(LoadKind::Interruptible), false),
217            ControlType::Ombc
218        );
219        assert_eq!(
220            control_type_for(&load(LoadKind::Fixed), false),
221            ControlType::NotControllable
222        );
223    }
224
225    #[test]
226    fn an_inverter_is_only_ever_an_envelope() {
227        let pv = Asset::Pv(PvArray {
228            meta: meta("pv", 9.8),
229            kwp_dc: Power::from_kw(9.8),
230            ac_nominal: Power::from_kw(8.0),
231            tilt_deg: 35.0,
232            azimuth_deg: 180.0,
233            cap_relief: CapRelief::None,
234        });
235        assert_eq!(control_type_for(&pv, false), ControlType::Pebc);
236        assert_eq!(roles_for(&pv), vec![RoleType::EnergyProducer]);
237    }
238
239    #[test]
240    fn a_battery_declares_three_roles_not_one() {
241        // A manager that assumes a single role plans a battery as a load.
242        let roles = roles_for(&battery());
243        assert!(roles.contains(&RoleType::EnergyStorage));
244        assert!(roles.contains(&RoleType::EnergyProducer));
245        assert!(roles.contains(&RoleType::EnergyConsumer));
246    }
247
248    #[test]
249    fn a_bidirectional_charge_point_can_produce_and_an_ordinary_one_cannot() {
250        assert!(roles_for(&evse(true)).contains(&RoleType::EnergyProducer));
251        assert_eq!(roles_for(&evse(false)), vec![RoleType::EnergyConsumer]);
252    }
253
254    #[test]
255    fn every_control_type_maps_onto_the_standards_own_enum() {
256        for ct in [
257            ControlType::Frbc,
258            ControlType::Pebc,
259            ControlType::Ombc,
260            ControlType::Ppbc,
261            ControlType::Ddbc,
262            ControlType::NotControllable,
263        ] {
264            let _: S2ControlType = ct.into();
265        }
266    }
267}