use hems_core::asset::{Battery, Evse, HeatPump, PvArray};
use hems_core::prelude::*;
use time::OffsetDateTime;
fn utc(at: OffsetDateTime) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::from_timestamp_nanos(
i64::try_from(at.unix_timestamp_nanos()).unwrap_or(i64::MAX),
)
}
use hems_device::SgReadyState;
use s2energy::common::{Commodity, CommodityQuantity, Duration, Id, NumberRange, PowerRange, Role};
use s2energy::{frbc, ombc, pebc};
use crate::map::{control_type_for, roles_for};
const HEMS_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9c1f0e-4b3a-5d2e-9a71-2c8e5b4d7f10");
fn stable_id(asset: &AssetId, part: &str) -> Id {
Id(uuid::Uuid::new_v5(
&HEMS_NAMESPACE,
format!("{asset}/{part}").as_bytes(),
))
}
pub const KWH_PER_S_PER_W: f64 = 1.0 / 3_600_000.0;
const PROCESSING_DELAY: Duration = Duration(1_000);
fn quantity(asset_phases: PhaseConnection, mode: PhaseMode) -> CommodityQuantity {
match asset_phases.clamp_mode(mode) {
PhaseMode::Single => CommodityQuantity::ElectricPowerL1,
PhaseMode::Three => CommodityQuantity::ElectricPower3PhaseSymmetric,
}
}
#[must_use]
pub fn resource_manager_details(
asset: &Asset,
mode: PhaseMode,
has_deadline: bool,
) -> s2energy::common::ResourceManagerDetails {
let roles = roles_for(asset)
.into_iter()
.map(|role| Role {
role,
commodity: Commodity::Electricity,
})
.collect();
s2energy::common::ResourceManagerDetails::builder()
.message_id(Id::generate())
.resource_id(stable_id(asset.id(), "resource"))
.name(asset.id().to_string())
.roles(roles)
.instruction_processing_delay(PROCESSING_DELAY)
.available_control_types(vec![control_type_for(asset, has_deadline).into()])
.provides_forecast(false)
.provides_power_measurement_types(vec![quantity(asset.meta().phases, mode)])
.build()
}
#[derive(Debug, Clone)]
pub struct BatteryDescription {
pub system: frbc::SystemDescription,
pub actuator: Id,
pub charge: Id,
pub discharge: Id,
}
#[must_use]
pub fn describe_battery(battery: &Battery, valid_from: OffsetDateTime) -> BatteryDescription {
let capacity = battery.capacity.kwh();
let usable = NumberRange {
start_of_range: battery.soc_min.fraction() * capacity,
end_of_range: battery.soc_max.fraction() * capacity,
};
let q = quantity(battery.meta.phases, battery.meta.phases.default_mode());
let charge_rate = battery.max_charge.get() * battery.efficiency_charge * KWH_PER_S_PER_W;
let discharge_rate = battery.max_discharge.get() * KWH_PER_S_PER_W
/ battery.efficiency_discharge.max(f64::EPSILON);
let charge = stable_id(&battery.meta.id, "battery/charge");
let discharge = stable_id(&battery.meta.id, "battery/discharge");
let actuator = stable_id(&battery.meta.id, "battery/inverter");
let mode = |id: &Id, label: &str, rate_end: f64, power_end: f64| {
frbc::OperationMode::builder()
.id(id.clone())
.diagnostic_label(label)
.abnormal_condition_only(false)
.elements(vec![frbc::OperationModeElement {
fill_level_range: usable.clone(),
fill_rate: NumberRange {
start_of_range: 0.0,
end_of_range: rate_end,
},
power_ranges: vec![PowerRange {
start_of_range: 0.0,
end_of_range: power_end,
commodity_quantity: q,
}],
running_costs: None,
}])
.build()
};
let system = frbc::SystemDescription::builder()
.message_id(Id::generate())
.valid_from(utc(valid_from))
.actuators(vec![
frbc::ActuatorDescription::builder()
.id(actuator.clone())
.diagnostic_label("inverter")
.supported_commodities(vec![Commodity::Electricity])
.operation_modes(vec![
mode(&charge, "charge", charge_rate, battery.max_charge.get()),
mode(
&discharge,
"discharge",
-discharge_rate,
-battery.max_discharge.get(),
),
])
.transitions(Vec::new())
.timers(Vec::new())
.build(),
])
.storage(
frbc::StorageDescription::builder()
.diagnostic_label("battery")
.fill_level_label("kWh")
.fill_level_range(usable.clone())
.provides_leakage_behaviour(false)
.provides_fill_level_target_profile(false)
.provides_usage_forecast(false)
.build(),
)
.build();
BatteryDescription {
system,
actuator,
charge,
discharge,
}
}
#[must_use]
pub fn describe_evse(
evse: &Evse,
mode: PhaseMode,
valid_from: OffsetDateTime,
) -> pebc::PowerConstraints {
let to_power = |c: Current| match evse.meta.phases.clamp_mode(mode) {
PhaseMode::Single => c.to_power_1p(NOMINAL_VOLTAGE),
PhaseMode::Three => c.to_power_3p(NOMINAL_VOLTAGE),
};
let floor = to_power(evse.min_current).get();
let ceiling = to_power(evse.max_current)
.get()
.min(evse.meta.connection_power.get());
pebc::PowerConstraints::builder()
.message_id(Id::generate())
.id(stable_id(&evse.meta.id, "evse/envelope"))
.valid_from(utc(valid_from))
.consequence_type(pebc::PowerEnvelopeConsequenceType::Defer)
.allowed_limit_ranges(vec![pebc::AllowedLimitRange {
commodity_quantity: quantity(evse.meta.phases, mode),
limit_type: pebc::PowerEnvelopeLimitType::UpperLimit,
range_boundary: NumberRange {
start_of_range: floor,
end_of_range: ceiling,
},
abnormal_condition_only: false,
}])
.build()
}
#[must_use]
pub fn describe_pv(pv: &PvArray, valid_from: OffsetDateTime) -> pebc::PowerConstraints {
pebc::PowerConstraints::builder()
.message_id(Id::generate())
.id(stable_id(&pv.meta.id, "pv/envelope"))
.valid_from(utc(valid_from))
.consequence_type(pebc::PowerEnvelopeConsequenceType::Vanish)
.allowed_limit_ranges(vec![pebc::AllowedLimitRange {
commodity_quantity: quantity(pv.meta.phases, pv.meta.phases.default_mode()),
limit_type: pebc::PowerEnvelopeLimitType::LowerLimit,
range_boundary: NumberRange {
start_of_range: -pv.ac_nominal.get(),
end_of_range: 0.0,
},
abnormal_condition_only: false,
}])
.build()
}
#[derive(Debug, Clone)]
pub struct HeatPumpDescription {
pub system: ombc::SystemDescription,
pub modes: [(Id, SgReadyState); 3],
}
impl HeatPumpDescription {
#[must_use]
pub fn state_of(&self, id: &Id) -> Option<SgReadyState> {
self.modes
.iter()
.find(|(mode, _)| mode == id)
.map(|(_, state)| *state)
}
}
#[must_use]
pub fn describe_heat_pump(
hp: &HeatPump,
grid_connection_power: Power,
valid_from: OffsetDateTime,
) -> HeatPumpDescription {
let q = quantity(hp.meta.phases, hp.meta.phases.default_mode());
let states = [
SgReadyState::Limited,
SgReadyState::Normal,
SgReadyState::Boost,
]
.map(|state| {
let expected =
hems_device::expected_power(state, hp.electrical_nominal, grid_connection_power);
(Id::generate(), state, expected)
});
let system = ombc::SystemDescription::builder()
.message_id(Id::generate())
.valid_from(utc(valid_from))
.operation_modes(
states
.iter()
.map(|(id, state, expected)| {
ombc::OperationMode::builder()
.id(id.clone())
.diagnostic_label(format!("SG Ready {}", state.number()))
.abnormal_condition_only(false)
.power_ranges(vec![PowerRange {
start_of_range: 0.0,
end_of_range: expected.get(),
commodity_quantity: q,
}])
.build()
})
.collect(),
)
.transitions(Vec::new())
.timers(Vec::new())
.build();
HeatPumpDescription {
system,
modes: states.map(|(id, state, _)| (id, state)),
}
}