1use hems_core::asset::{Battery, Evse, HeatPump, PvArray};
17use hems_core::prelude::*;
18use time::OffsetDateTime;
19
20fn 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
33const HEMS_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9c1f0e-4b3a-5d2e-9a71-2c8e5b4d7f10");
37
38fn 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
53pub const KWH_PER_S_PER_W: f64 = 1.0 / 3_600_000.0;
56
57const PROCESSING_DELAY: Duration = Duration(1_000);
62
63fn 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#[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#[derive(Debug, Clone)]
107pub struct BatteryDescription {
108 pub system: frbc::SystemDescription,
110 pub actuator: Id,
112 pub charge: Id,
114 pub discharge: Id,
116}
117
118#[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 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 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#[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 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#[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 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#[derive(Debug, Clone)]
278pub struct HeatPumpDescription {
279 pub system: ombc::SystemDescription,
281 pub modes: [(Id, SgReadyState); 3],
283}
284
285impl HeatPumpDescription {
286 #[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#[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}