Skip to main content

hems_flex/
instruct.rs

1//! Reading an S2 instruction back into something the site can execute.
2//!
3//! An instruction is not a command. It names an operation mode by ID and gives
4//! a factor in `[0, 1]`; turning that into watts needs the description that was
5//! sent, which is why every function here takes one. A Resource Manager that
6//! interprets an instruction without reference to what it advertised is
7//! guessing.
8
9use hems_core::asset::Battery;
10use hems_core::prelude::*;
11use hems_device::SgReadyState;
12use s2energy::{frbc, ombc, pebc};
13
14use crate::describe::{BatteryDescription, HeatPumpDescription};
15
16/// Why an instruction could not be executed.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum InstructError {
19    /// The instruction names an actuator this Resource Manager never described.
20    #[error("instruction addresses an unknown actuator")]
21    UnknownActuator,
22    /// The instruction names an operation mode this Resource Manager never
23    /// described. Refusing is the only safe answer: a mode we cannot name is a
24    /// mode whose power range we do not know.
25    #[error("instruction names an unknown operation mode")]
26    UnknownOperationMode,
27    /// The factor was outside `[0, 1]`, or not a number.
28    #[error("operation mode factor {0} is outside [0, 1]")]
29    FactorOutOfRange(String),
30    /// The instruction carried no envelope element to read a limit from.
31    #[error("power envelope contains no elements")]
32    EmptyEnvelope,
33    /// No envelope in the instruction covers the commodity the asset consumes.
34    #[error("no power envelope for this asset's commodity")]
35    NoMatchingEnvelope,
36}
37
38fn factor(value: f64) -> Result<f64, InstructError> {
39    if value.is_finite() && (0.0..=1.0).contains(&value) {
40        Ok(value)
41    } else {
42        Err(InstructError::FactorOutOfRange(value.to_string()))
43    }
44}
45
46/// The active power an FRBC instruction asks a battery for.
47///
48/// # Errors
49/// [`InstructError`] when the instruction names something that was never
50/// described, or carries a factor outside `[0, 1]`.
51pub fn battery_power(
52    description: &BatteryDescription,
53    instruction: &frbc::Instruction,
54    battery: &Battery,
55) -> Result<Power, InstructError> {
56    if instruction.actuator_id != description.actuator {
57        return Err(InstructError::UnknownActuator);
58    }
59    let f = factor(instruction.operation_mode_factor)?;
60
61    if instruction.operation_mode == description.charge {
62        Ok(Power::new(battery.max_charge.get() * f))
63    } else if instruction.operation_mode == description.discharge {
64        // Load convention: discharging is negative.
65        Ok(Power::new(-battery.max_discharge.get() * f))
66    } else {
67        Err(InstructError::UnknownOperationMode)
68    }
69}
70
71/// The SG Ready state an OMBC instruction selects.
72///
73/// The factor is ignored: SG Ready has no continuum, and an instruction that
74/// asks for half of "boost" is asking for boost.
75///
76/// # Errors
77/// [`InstructError::UnknownOperationMode`] when the mode was never described.
78pub fn heat_pump_state(
79    description: &HeatPumpDescription,
80    instruction: &ombc::Instruction,
81) -> Result<SgReadyState, InstructError> {
82    description
83        .state_of(&instruction.operation_mode_id)
84        .ok_or(InstructError::UnknownOperationMode)
85}
86
87/// The nearest-term ceiling a PEBC instruction imposes, for the given commodity.
88///
89/// S2 sends a whole envelope over time; the arbiter runs now, so the element in
90/// force now is the one that matters. The rest belongs to the optimiser, which
91/// takes it as a forecast rather than a command.
92///
93/// # Errors
94/// [`InstructError::NoMatchingEnvelope`] or [`InstructError::EmptyEnvelope`].
95pub fn envelope_now(
96    instruction: &pebc::Instruction,
97    quantity: s2energy::common::CommodityQuantity,
98) -> Result<(Power, Power), InstructError> {
99    let envelope = instruction
100        .power_envelopes
101        .iter()
102        .find(|e| e.commodity_quantity == quantity)
103        .ok_or(InstructError::NoMatchingEnvelope)?;
104    let element = envelope
105        .power_envelope_elements
106        .first()
107        .ok_or(InstructError::EmptyEnvelope)?;
108    Ok((
109        Power::new(element.lower_limit),
110        Power::new(element.upper_limit),
111    ))
112}
113
114/// The command that carries out a PEBC instruction on `asset`.
115///
116/// A producer is bounded from below (curtailment), a consumer from above. Which
117/// end of the envelope is the operative one is a property of the asset, not of
118/// the message — an inverter handed a `ConsumptionCeiling` would ignore it.
119///
120/// # Errors
121/// [`InstructError`] when the instruction carries nothing usable for the asset.
122pub fn envelope_command(
123    instruction: &pebc::Instruction,
124    asset: &Asset,
125    mode: PhaseMode,
126) -> Result<Command, InstructError> {
127    let quantity = match asset.meta().phases.clamp_mode(mode) {
128        PhaseMode::Single => s2energy::common::CommodityQuantity::ElectricPowerL1,
129        PhaseMode::Three => s2energy::common::CommodityQuantity::ElectricPower3PhaseSymmetric,
130    };
131    let (lower, upper) = envelope_now(instruction, quantity)?;
132
133    Ok(match asset {
134        Asset::Pv(_) => Command::ProductionCeiling(Power::new(-lower.get()).max(Power::ZERO)),
135        _ => Command::ConsumptionCeiling(upper.max(Power::ZERO)),
136    })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::describe::{
143        HeatPumpDescription, describe_battery, describe_evse, describe_heat_pump, describe_pv,
144    };
145    use hems_core::asset::{AssetMeta, Chemistry, Evse, HeatPump, PvArray};
146    use s2energy::common::{Duration, Id};
147    use time::OffsetDateTime;
148    use time::macros::datetime;
149
150    const T0: OffsetDateTime = datetime!(2026-01-15 12:00 UTC);
151
152    fn meta(id: &str, kw: f64, phases: PhaseConnection) -> AssetMeta {
153        AssetMeta::new(
154            AssetId::new(id).unwrap(),
155            CircuitId::new("main").unwrap(),
156            phases,
157            Power::from_kw(kw),
158        )
159    }
160
161    fn battery() -> Battery {
162        Battery {
163            meta: meta("battery", 5.0, PhaseConnection::Three),
164            capacity: Energy::from_kwh(10.0),
165            max_charge: Power::from_kw(5.0),
166            max_discharge: Power::from_kw(5.0),
167            efficiency_charge: 0.95,
168            efficiency_discharge: 0.95,
169            soc_min: Soc::new(0.1).unwrap(),
170            soc_max: Soc::FULL,
171            reserve_soc: Soc::EMPTY,
172            chemistry: Chemistry::Lfp,
173            grid_charging_allowed: true,
174        }
175    }
176
177    fn frbc_instruction(actuator: Id, mode: Id, f: f64) -> frbc::Instruction {
178        frbc::Instruction::builder()
179            .message_id(Id::generate())
180            .id(Id::generate())
181            .actuator_id(actuator)
182            .operation_mode(mode)
183            .operation_mode_factor(f)
184            .execution_time(chrono::DateTime::from_timestamp_nanos(
185                i64::try_from(T0.unix_timestamp_nanos()).unwrap(),
186            ))
187            .abnormal_condition(false)
188            .build()
189    }
190
191    #[test]
192    fn a_charge_instruction_becomes_positive_power_and_a_discharge_one_negative() {
193        let b = battery();
194        let d = describe_battery(&b, T0);
195
196        let charge = frbc_instruction(d.actuator.clone(), d.charge.clone(), 1.0);
197        assert_eq!(battery_power(&d, &charge, &b).unwrap(), Power::from_kw(5.0));
198
199        let discharge = frbc_instruction(d.actuator.clone(), d.discharge.clone(), 1.0);
200        assert_eq!(
201            battery_power(&d, &discharge, &b).unwrap(),
202            Power::from_kw(-5.0)
203        );
204    }
205
206    #[test]
207    fn factor_zero_is_idle_in_either_mode() {
208        // The reason both modes start at idle: a manager can stop the battery
209        // without first switching mode, so it never overshoots on a change of
210        // mind.
211        let b = battery();
212        let d = describe_battery(&b, T0);
213        for mode in [d.charge.clone(), d.discharge.clone()] {
214            let idle = frbc_instruction(d.actuator.clone(), mode, 0.0);
215            assert_eq!(battery_power(&d, &idle, &b).unwrap(), Power::ZERO);
216        }
217    }
218
219    #[test]
220    fn an_unknown_mode_or_actuator_is_refused_rather_than_guessed() {
221        let b = battery();
222        let d = describe_battery(&b, T0);
223        assert_eq!(
224            battery_power(
225                &d,
226                &frbc_instruction(d.actuator.clone(), Id::generate(), 1.0),
227                &b
228            ),
229            Err(InstructError::UnknownOperationMode)
230        );
231        assert_eq!(
232            battery_power(
233                &d,
234                &frbc_instruction(Id::generate(), d.charge.clone(), 1.0),
235                &b
236            ),
237            Err(InstructError::UnknownActuator)
238        );
239    }
240
241    #[test]
242    fn a_factor_outside_the_unit_interval_is_refused() {
243        let b = battery();
244        let d = describe_battery(&b, T0);
245        for bad in [1.5, -0.1, f64::NAN] {
246            assert!(matches!(
247                battery_power(
248                    &d,
249                    &frbc_instruction(d.actuator.clone(), d.charge.clone(), bad),
250                    &b
251                ),
252                Err(InstructError::FactorOutOfRange(_))
253            ));
254        }
255    }
256
257    #[test]
258    fn the_described_fill_rate_accounts_for_the_round_trip_loss() {
259        // 5 kW into a 95 %-efficient battery stores 4.75 kWh per hour. A manager
260        // planning on 5 would think the battery full a quarter of an hour early.
261        let d = describe_battery(&battery(), T0);
262        let charge = &d.system.actuators[0].operation_modes[0].elements[0];
263        let stored_per_hour = charge.fill_rate.end_of_range * 3600.0;
264        assert!((stored_per_hour - 4.75).abs() < 1e-9, "{stored_per_hour}");
265    }
266
267    #[test]
268    fn describing_the_same_asset_twice_yields_the_same_identifiers() {
269        // The whole reason the IDs are derived rather than generated: an
270        // instruction names an operation mode by ID, so a Resource Manager that
271        // re-mints them on reconnect invalidates every description the manager
272        // cached — and a manager replaying a ten-minute-old plan addresses modes
273        // that no longer exist.
274        let b = battery();
275        let first = describe_battery(&b, T0);
276        let second = describe_battery(&b, T0 + time::Duration::hours(3));
277        assert_eq!(first.charge, second.charge);
278        assert_eq!(first.discharge, second.discharge);
279        assert_eq!(first.actuator, second.actuator);
280
281        // An instruction issued against the first description is still
282        // executable against the second.
283        let instruction = frbc_instruction(first.actuator.clone(), first.charge.clone(), 1.0);
284        assert_eq!(
285            battery_power(&second, &instruction, &b).unwrap(),
286            Power::from_kw(5.0)
287        );
288    }
289
290    #[test]
291    fn two_batteries_do_not_share_identifiers() {
292        let mut other = battery();
293        other.meta = meta("battery-2", 5.0, PhaseConnection::Three);
294        assert_ne!(
295            describe_battery(&battery(), T0).charge,
296            describe_battery(&other, T0).charge
297        );
298    }
299
300    #[test]
301    fn the_usable_fill_level_range_excludes_the_reserved_bottom() {
302        let d = describe_battery(&battery(), T0);
303        let range = &d.system.storage.fill_level_range;
304        assert!((range.start_of_range - 1.0).abs() < 1e-9);
305        assert!((range.end_of_range - 10.0).abs() < 1e-9);
306    }
307
308    fn evse() -> Evse {
309        Evse {
310            meta: meta("wallbox", 11.0, PhaseConnection::Three),
311            min_current: Current::new(6.0),
312            max_current: Current::new(16.0),
313            bidirectional: false,
314            public: false,
315        }
316    }
317
318    #[test]
319    fn a_charge_points_envelope_floor_is_its_minimum_current_not_zero() {
320        // Below 6 A a wallbox cannot operate. An envelope that says 0 invites a
321        // manager to allocate 2 kW and wonder why nothing charges.
322        let c = describe_evse(&evse(), PhaseMode::Three, T0);
323        let range = &c.allowed_limit_ranges[0].range_boundary;
324        assert!((range.start_of_range - 4140.0).abs() < 1.0, "{range:?}");
325        assert!((range.end_of_range - 11000.0).abs() < 1.0, "{range:?}");
326    }
327
328    #[test]
329    fn deferring_a_car_and_losing_the_sun_are_different_consequences() {
330        // The one field that tells a manager curtailing PV costs energy for good
331        // while curtailing a wallbox only moves it.
332        assert_eq!(
333            describe_evse(&evse(), PhaseMode::Three, T0).consequence_type,
334            pebc::PowerEnvelopeConsequenceType::Defer
335        );
336        assert_eq!(
337            describe_pv(&pv(), T0).consequence_type,
338            pebc::PowerEnvelopeConsequenceType::Vanish
339        );
340    }
341
342    fn pv() -> PvArray {
343        PvArray {
344            meta: meta("pv", 9.8, PhaseConnection::Three),
345            kwp_dc: Power::from_kw(9.8),
346            ac_nominal: Power::from_kw(8.0),
347            tilt_deg: 35.0,
348            azimuth_deg: 180.0,
349            cap_relief: CapRelief::None,
350        }
351    }
352
353    fn pebc_instruction(
354        quantity: s2energy::common::CommodityQuantity,
355        lower: f64,
356        upper: f64,
357    ) -> pebc::Instruction {
358        pebc::Instruction::builder()
359            .message_id(Id::generate())
360            .id(Id::generate())
361            .execution_time(chrono::DateTime::from_timestamp_nanos(
362                i64::try_from(T0.unix_timestamp_nanos()).unwrap(),
363            ))
364            .abnormal_condition(false)
365            .power_constraints_id(Id::generate())
366            .power_envelopes(vec![pebc::PowerEnvelope {
367                id: Id::generate(),
368                commodity_quantity: quantity,
369                power_envelope_elements: vec![pebc::PowerEnvelopeElement {
370                    duration: Duration(900_000),
371                    lower_limit: lower,
372                    upper_limit: upper,
373                }],
374            }])
375            .build()
376    }
377
378    #[test]
379    fn an_envelope_bounds_a_consumer_from_above_and_a_producer_from_below() {
380        let q = s2energy::common::CommodityQuantity::ElectricPower3PhaseSymmetric;
381        let instruction = pebc_instruction(q, -4000.0, 4200.0);
382
383        let wallbox = Asset::Evse(evse());
384        assert_eq!(
385            envelope_command(&instruction, &wallbox, PhaseMode::Three).unwrap(),
386            Command::ConsumptionCeiling(Power::from_kw(4.2))
387        );
388
389        // Same message, opposite end read — because an inverter handed a
390        // consumption ceiling would ignore it.
391        let inverter = Asset::Pv(pv());
392        assert_eq!(
393            envelope_command(&instruction, &inverter, PhaseMode::Three).unwrap(),
394            Command::ProductionCeiling(Power::from_kw(4.0))
395        );
396    }
397
398    #[test]
399    fn an_envelope_for_another_commodity_is_not_silently_applied() {
400        let instruction = pebc_instruction(
401            s2energy::common::CommodityQuantity::HeatThermalPower,
402            0.0,
403            4200.0,
404        );
405        assert_eq!(
406            envelope_command(&instruction, &Asset::Evse(evse()), PhaseMode::Three),
407            Err(InstructError::NoMatchingEnvelope)
408        );
409    }
410
411    #[test]
412    fn a_switchable_charge_point_reads_a_different_envelope_in_each_mode() {
413        // The description a Customer Energy Manager is sent depends on the mode
414        // the contactor is actually in, not on the wiring: an envelope for
415        // `ElectricPower3PhaseSymmetric` says nothing about a device currently
416        // drawing on one conductor.
417        let mut switchable = evse();
418        switchable.meta = meta(
419            "wallbox",
420            11.0,
421            PhaseConnection::Switchable { phase: Phase::L1 },
422        );
423        let one_phase = pebc_instruction(
424            s2energy::common::CommodityQuantity::ElectricPowerL1,
425            0.0,
426            3000.0,
427        );
428        let asset = Asset::Evse(switchable);
429        assert_eq!(
430            envelope_command(&one_phase, &asset, PhaseMode::Single).unwrap(),
431            Command::ConsumptionCeiling(Power::from_kw(3.0))
432        );
433        assert_eq!(
434            envelope_command(&one_phase, &asset, PhaseMode::Three),
435            Err(InstructError::NoMatchingEnvelope)
436        );
437    }
438
439    #[test]
440    fn a_single_phase_asset_reads_its_own_conductors_envelope() {
441        let mut single = evse();
442        single.meta = meta("wallbox", 3.7, PhaseConnection::Single { phase: Phase::L1 });
443        let instruction = pebc_instruction(
444            s2energy::common::CommodityQuantity::ElectricPowerL1,
445            0.0,
446            3000.0,
447        );
448        assert_eq!(
449            envelope_command(&instruction, &Asset::Evse(single), PhaseMode::Single).unwrap(),
450            Command::ConsumptionCeiling(Power::from_kw(3.0))
451        );
452    }
453
454    fn heat_pump() -> HeatPump {
455        HeatPump {
456            meta: meta("wp", 9.0, PhaseConnection::Three),
457            electrical_nominal: Power::from_kw(4.0),
458            heating_rod: None,
459            control: HeatPumpControl::SgReady,
460            modulating: true,
461        }
462    }
463
464    #[test]
465    fn a_heat_pump_is_described_with_three_modes_and_each_maps_back() {
466        let d = describe_heat_pump(&heat_pump(), Power::from_kw(30.0), T0);
467        assert_eq!(d.system.operation_modes.len(), 3);
468        for (id, state) in &d.modes {
469            let instruction = ombc::Instruction::builder()
470                .message_id(Id::generate())
471                .id(Id::generate())
472                .execution_time(chrono::DateTime::from_timestamp_nanos(
473                    i64::try_from(T0.unix_timestamp_nanos()).unwrap(),
474                ))
475                .operation_mode_id(id.clone())
476                .operation_mode_factor(1.0)
477                .abnormal_condition(false)
478                .build();
479            assert_eq!(heat_pump_state(&d, &instruction).unwrap(), *state);
480        }
481    }
482
483    #[test]
484    fn a_heat_pump_mode_we_never_described_is_refused() {
485        let d = describe_heat_pump(&heat_pump(), Power::from_kw(30.0), T0);
486        let instruction = ombc::Instruction::builder()
487            .message_id(Id::generate())
488            .id(Id::generate())
489            .execution_time(chrono::DateTime::from_timestamp_nanos(
490                i64::try_from(T0.unix_timestamp_nanos()).unwrap(),
491            ))
492            .operation_mode_id(Id::generate())
493            .operation_mode_factor(1.0)
494            .abnormal_condition(false)
495            .build();
496        assert_eq!(
497            heat_pump_state(&d, &instruction),
498            Err(InstructError::UnknownOperationMode)
499        );
500    }
501
502    #[test]
503    fn the_limited_mode_is_not_always_the_quietest_one() {
504        // A fact worth encoding, because it surprises everyone: § 14a's state-1
505        // value is a *guaranteed minimum*, not a reduction. A 4 kW heat pump on
506        // a 30 kW connection is guaranteed 12 kW it cannot use, so its state 1
507        // is its full rating — higher than the half-load of "normal".
508        let small = describe_heat_pump(&heat_pump(), Power::from_kw(30.0), T0);
509        let power_of = |d: &HeatPumpDescription, i: usize| {
510            d.system.operation_modes[i].power_ranges[0].end_of_range
511        };
512        assert!(
513            (power_of(&small, 0) - 4000.0).abs() < 1.0,
514            "state 1 does not limit this unit"
515        );
516        assert!(power_of(&small, 1) < power_of(&small, 0));
517
518        // A unit large enough for the value to bite orders the way one expects.
519        let mut big = heat_pump();
520        big.electrical_nominal = Power::from_kw(12.0);
521        let large = describe_heat_pump(&big, Power::from_kw(11.0), T0);
522        assert!(power_of(&large, 0) < power_of(&large, 1));
523        assert!(power_of(&large, 1) < power_of(&large, 2));
524    }
525}