Skip to main content

hems_flex/
describe.rs

1//! What a Resource Manager tells a Customer Energy Manager it can do.
2//!
3//! Each function returns the S2 description *and* the identifiers inside it,
4//! because an instruction refers to an operation mode by ID and there is no way
5//! to act on one you cannot name. Handing back a bare `SystemDescription` would
6//! be a description you can send but not obey.
7//!
8//! # Fill level units
9//!
10//! S2 leaves the fill level unit to the Resource Manager and requires only that
11//! the fill *rate* be expressed in **that unit per second**. hems uses
12//! kilowatt-hours for every store — a battery, a hot water tank and a car all
13//! hold energy — so a fill rate is kWh/s. The number is small; that is what the
14//! standard asks for, and [`KWH_PER_S_PER_W`] converts once, in one place.
15
16use hems_core::asset::{Battery, Evse, HeatPump, PvArray};
17use hems_core::prelude::*;
18use time::OffsetDateTime;
19
20/// S2's generated types carry `chrono` timestamps; the rest of hems uses
21/// `time`. Converting in one place beats letting two clocks into the domain.
22fn utc(at: OffsetDateTime) -> chrono::DateTime<chrono::Utc> {
23    chrono::DateTime::from_timestamp_nanos(
24        i64::try_from(at.unix_timestamp_nanos()).unwrap_or(i64::MAX),
25    )
26}
27use hems_device::SgReadyState;
28use s2energy::common::{Commodity, CommodityQuantity, Duration, Id, NumberRange, PowerRange, Role};
29use s2energy::{frbc, ombc, pebc};
30
31use crate::map::{control_type_for, roles_for};
32
33/// The namespace hems mints its S2 identifiers in.
34///
35/// Randomly chosen once, then fixed forever — that is what a UUID namespace is.
36const HEMS_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9c1f0e-4b3a-5d2e-9a71-2c8e5b4d7f10");
37
38/// A stable S2 identifier for one named part of one asset.
39///
40/// **Deterministic on purpose.** S2 instructions name an operation mode by ID,
41/// so a Resource Manager that re-mints its IDs on every reconnect invalidates
42/// every description a Customer Energy Manager cached — and a manager that
43/// replays a plan it made ten minutes ago addresses modes that no longer exist.
44/// Deriving them from the asset's own identity means a restart changes nothing,
45/// which is the behaviour a manager is entitled to assume.
46fn stable_id(asset: &AssetId, part: &str) -> Id {
47    Id(uuid::Uuid::new_v5(
48        &HEMS_NAMESPACE,
49        format!("{asset}/{part}").as_bytes(),
50    ))
51}
52
53/// A watt sustained for a second is this many kilowatt-hours — the conversion
54/// from a power the hardware quotes to the fill rate S2 wants.
55pub const KWH_PER_S_PER_W: f64 = 1.0 / 3_600_000.0;
56
57/// How long hems takes to act on an instruction, as advertised to a CEM.
58///
59/// One control tick. Claiming less would invite a manager to plan on a
60/// responsiveness the arbiter does not have.
61const PROCESSING_DELAY: Duration = Duration(1_000);
62
63/// Which commodity a device's power is measured in, given the mode it is in.
64///
65/// A switchable charge point changes this when it switches, which is why the
66/// mode is a parameter and not a property of the wiring: a description that
67/// still says `ElectricPower3PhaseSymmetric` after the contactor has dropped two
68/// conductors is describing a device that no longer exists.
69fn quantity(asset_phases: PhaseConnection, mode: PhaseMode) -> CommodityQuantity {
70    match asset_phases.clamp_mode(mode) {
71        PhaseMode::Single => CommodityQuantity::ElectricPowerL1,
72        PhaseMode::Three => CommodityQuantity::ElectricPower3PhaseSymmetric,
73    }
74}
75
76/// The Resource Manager announcement for any asset — who it is, what roles it
77/// plays, and which control type it will accept instructions in.
78#[must_use]
79pub fn resource_manager_details(
80    asset: &Asset,
81    mode: PhaseMode,
82    has_deadline: bool,
83) -> s2energy::common::ResourceManagerDetails {
84    let roles = roles_for(asset)
85        .into_iter()
86        .map(|role| Role {
87            role,
88            commodity: Commodity::Electricity,
89        })
90        .collect();
91
92    s2energy::common::ResourceManagerDetails::builder()
93        .message_id(Id::generate())
94        .resource_id(stable_id(asset.id(), "resource"))
95        .name(asset.id().to_string())
96        .roles(roles)
97        .instruction_processing_delay(PROCESSING_DELAY)
98        .available_control_types(vec![control_type_for(asset, has_deadline).into()])
99        .provides_forecast(false)
100        .provides_power_measurement_types(vec![quantity(asset.meta().phases, mode)])
101        .build()
102}
103
104/// A battery, described as fill-rate-based control, with the IDs needed to read
105/// an instruction back.
106#[derive(Debug, Clone)]
107pub struct BatteryDescription {
108    /// The description to send.
109    pub system: frbc::SystemDescription,
110    /// The single actuator: the inverter.
111    pub actuator: Id,
112    /// Charging. Factor 0 is idle, factor 1 is full charge power.
113    pub charge: Id,
114    /// Discharging. Factor 0 is idle, factor 1 is full discharge power.
115    pub discharge: Id,
116}
117
118/// Describe a battery as a store with a level and two directions.
119///
120/// Both operation modes start at idle, so an `operation_mode_factor` of zero
121/// means *stop* whichever mode is active. A manager that has to switch mode to
122/// stop a battery will overshoot every time it changes its mind.
123#[must_use]
124pub fn describe_battery(battery: &Battery, valid_from: OffsetDateTime) -> BatteryDescription {
125    let capacity = battery.capacity.kwh();
126    let usable = NumberRange {
127        start_of_range: battery.soc_min.fraction() * capacity,
128        end_of_range: battery.soc_max.fraction() * capacity,
129    };
130    let q = quantity(battery.meta.phases, battery.meta.phases.default_mode());
131
132    // Round-trip losses belong in the *rate*, not the power: a battery told to
133    // draw 5 kW stores less than 5 kWh per hour, and a manager that plans on the
134    // electrical figure will believe the battery is full before it is.
135    let charge_rate = battery.max_charge.get() * battery.efficiency_charge * KWH_PER_S_PER_W;
136    let discharge_rate = battery.max_discharge.get() * KWH_PER_S_PER_W
137        / battery.efficiency_discharge.max(f64::EPSILON);
138
139    let charge = stable_id(&battery.meta.id, "battery/charge");
140    let discharge = stable_id(&battery.meta.id, "battery/discharge");
141    let actuator = stable_id(&battery.meta.id, "battery/inverter");
142
143    let mode = |id: &Id, label: &str, rate_end: f64, power_end: f64| {
144        frbc::OperationMode::builder()
145            .id(id.clone())
146            .diagnostic_label(label)
147            .abnormal_condition_only(false)
148            .elements(vec![frbc::OperationModeElement {
149                fill_level_range: usable.clone(),
150                fill_rate: NumberRange {
151                    start_of_range: 0.0,
152                    end_of_range: rate_end,
153                },
154                power_ranges: vec![PowerRange {
155                    start_of_range: 0.0,
156                    end_of_range: power_end,
157                    commodity_quantity: q,
158                }],
159                running_costs: None,
160            }])
161            .build()
162    };
163
164    let system = frbc::SystemDescription::builder()
165        .message_id(Id::generate())
166        .valid_from(utc(valid_from))
167        .actuators(vec![
168            frbc::ActuatorDescription::builder()
169                .id(actuator.clone())
170                .diagnostic_label("inverter")
171                .supported_commodities(vec![Commodity::Electricity])
172                .operation_modes(vec![
173                    mode(&charge, "charge", charge_rate, battery.max_charge.get()),
174                    // Load convention: discharging is negative power, and it
175                    // empties the store, so the rate is negative too.
176                    mode(
177                        &discharge,
178                        "discharge",
179                        -discharge_rate,
180                        -battery.max_discharge.get(),
181                    ),
182                ])
183                .transitions(Vec::new())
184                .timers(Vec::new())
185                .build(),
186        ])
187        .storage(
188            frbc::StorageDescription::builder()
189                .diagnostic_label("battery")
190                .fill_level_label("kWh")
191                .fill_level_range(usable.clone())
192                .provides_leakage_behaviour(false)
193                .provides_fill_level_target_profile(false)
194                .provides_usage_forecast(false)
195                .build(),
196        )
197        .build();
198
199    BatteryDescription {
200        system,
201        actuator,
202        charge,
203        discharge,
204    }
205}
206
207/// Describe a charge point as a power envelope.
208///
209/// The consequence of a tighter envelope is [`Defer`] — the car charges later,
210/// nothing is lost. That single field is why a manager may curtail a wallbox
211/// freely and must think twice about an inverter.
212///
213/// [`Defer`]: pebc::PowerEnvelopeConsequenceType::Defer
214#[must_use]
215pub fn describe_evse(
216    evse: &Evse,
217    mode: PhaseMode,
218    valid_from: OffsetDateTime,
219) -> pebc::PowerConstraints {
220    let to_power = |c: Current| match evse.meta.phases.clamp_mode(mode) {
221        PhaseMode::Single => c.to_power_1p(NOMINAL_VOLTAGE),
222        PhaseMode::Three => c.to_power_3p(NOMINAL_VOLTAGE),
223    };
224    // The floor is the minimum charging current, not zero: between zero and
225    // 6 A a charge point cannot operate at all, and a manager that believes
226    // 2 kW is available will keep the car idle while thinking it is charging.
227    let floor = to_power(evse.min_current).get();
228    let ceiling = to_power(evse.max_current)
229        .get()
230        .min(evse.meta.connection_power.get());
231
232    pebc::PowerConstraints::builder()
233        .message_id(Id::generate())
234        .id(stable_id(&evse.meta.id, "evse/envelope"))
235        .valid_from(utc(valid_from))
236        .consequence_type(pebc::PowerEnvelopeConsequenceType::Defer)
237        .allowed_limit_ranges(vec![pebc::AllowedLimitRange {
238            commodity_quantity: quantity(evse.meta.phases, mode),
239            limit_type: pebc::PowerEnvelopeLimitType::UpperLimit,
240            range_boundary: NumberRange {
241                start_of_range: floor,
242                end_of_range: ceiling,
243            },
244            abnormal_condition_only: false,
245        }])
246        .build()
247}
248
249/// Describe an inverter as a power envelope whose consequence is [`Vanish`].
250///
251/// Curtailed sunlight does not come back later. § 9 EEG and § 51 make hems ask
252/// for this often enough that saying so precisely matters.
253///
254/// [`Vanish`]: pebc::PowerEnvelopeConsequenceType::Vanish
255#[must_use]
256pub fn describe_pv(pv: &PvArray, valid_from: OffsetDateTime) -> pebc::PowerConstraints {
257    pebc::PowerConstraints::builder()
258        .message_id(Id::generate())
259        .id(stable_id(&pv.meta.id, "pv/envelope"))
260        .valid_from(utc(valid_from))
261        .consequence_type(pebc::PowerEnvelopeConsequenceType::Vanish)
262        .allowed_limit_ranges(vec![pebc::AllowedLimitRange {
263            commodity_quantity: quantity(pv.meta.phases, pv.meta.phases.default_mode()),
264            limit_type: pebc::PowerEnvelopeLimitType::LowerLimit,
265            // Load convention: full production is the most negative value the
266            // envelope may reach, and zero is full curtailment.
267            range_boundary: NumberRange {
268                start_of_range: -pv.ac_nominal.get(),
269                end_of_range: 0.0,
270            },
271            abnormal_condition_only: false,
272        }])
273        .build()
274}
275
276/// A heat pump's three SG Ready states, described as operation modes.
277#[derive(Debug, Clone)]
278pub struct HeatPumpDescription {
279    /// The description to send.
280    pub system: ombc::SystemDescription,
281    /// Operation mode IDs, in the order [`SgReadyState`] numbers them.
282    pub modes: [(Id, SgReadyState); 3],
283}
284
285impl HeatPumpDescription {
286    /// The SG Ready state an operation mode ID stands for.
287    #[must_use]
288    pub fn state_of(&self, id: &Id) -> Option<SgReadyState> {
289        self.modes
290            .iter()
291            .find(|(mode, _)| mode == id)
292            .map(|(_, state)| *state)
293    }
294}
295
296/// Describe an SG Ready heat pump as three operation modes.
297///
298/// Three, not four: BWP's SG Ready v1.1 defines state 1 (limited), 2 (normal)
299/// and 3 (boost) for an energy manager, and the fourth is the manufacturer's
300/// own forced-run signal, which no HEMS may assert.
301///
302/// The advertised power ranges are **not necessarily ordered**. § 14a's value
303/// for state 1 is a guaranteed *minimum* — 4,2 kW, or 40 % of the grid
304/// connection power above 11 kW — so a 4 kW heat pump on a 30 kW connection is
305/// guaranteed more than it can draw, and its state 1 is its full rating: higher
306/// than the half-load of state 2. A manager reading these ranges will do the
307/// right thing; one assuming the states descend will not.
308#[must_use]
309pub fn describe_heat_pump(
310    hp: &HeatPump,
311    grid_connection_power: Power,
312    valid_from: OffsetDateTime,
313) -> HeatPumpDescription {
314    let q = quantity(hp.meta.phases, hp.meta.phases.default_mode());
315    let states = [
316        SgReadyState::Limited,
317        SgReadyState::Normal,
318        SgReadyState::Boost,
319    ]
320    .map(|state| {
321        let expected =
322            hems_device::expected_power(state, hp.electrical_nominal, grid_connection_power);
323        (Id::generate(), state, expected)
324    });
325
326    let system = ombc::SystemDescription::builder()
327        .message_id(Id::generate())
328        .valid_from(utc(valid_from))
329        .operation_modes(
330            states
331                .iter()
332                .map(|(id, state, expected)| {
333                    ombc::OperationMode::builder()
334                        .id(id.clone())
335                        .diagnostic_label(format!("SG Ready {}", state.number()))
336                        .abnormal_condition_only(false)
337                        .power_ranges(vec![PowerRange {
338                            start_of_range: 0.0,
339                            end_of_range: expected.get(),
340                            commodity_quantity: q,
341                        }])
342                        .build()
343                })
344                .collect(),
345        )
346        .transitions(Vec::new())
347        .timers(Vec::new())
348        .build();
349
350    HeatPumpDescription {
351        system,
352        modes: states.map(|(id, state, _)| (id, state)),
353    }
354}