1use 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum InstructError {
19 #[error("instruction addresses an unknown actuator")]
21 UnknownActuator,
22 #[error("instruction names an unknown operation mode")]
26 UnknownOperationMode,
27 #[error("operation mode factor {0} is outside [0, 1]")]
29 FactorOutOfRange(String),
30 #[error("power envelope contains no elements")]
32 EmptyEnvelope,
33 #[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
46pub 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 Ok(Power::new(-battery.max_discharge.get() * f))
66 } else {
67 Err(InstructError::UnknownOperationMode)
68 }
69}
70
71pub 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
87pub 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
114pub 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 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 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 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 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 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 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 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 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 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 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}