use time::Date;
use crate::envelope::Envelope;
use crate::ids::{AssetId, CircuitId};
use crate::units::{Current, Energy, NOMINAL_VOLTAGE, PhaseConnection, PhaseMode, Power, Soc};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Capabilities(u16);
impl Capabilities {
pub const MEASURE: Self = Self(1 << 0);
pub const LIMIT_CONSUMPTION: Self = Self(1 << 1);
pub const LIMIT_PRODUCTION: Self = Self(1 << 2);
pub const SET_POWER: Self = Self(1 << 3);
pub const SET_MODE: Self = Self(1 << 4);
pub const SCHEDULE: Self = Self(1 << 5);
pub const BIDIRECTIONAL: Self = Self(1 << 7);
pub const IDENTIFY: Self = Self(1 << 8);
pub const NONE: Self = Self(0);
#[must_use]
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
impl core::ops::BitOr for Capabilities {
type Output = Self;
fn bitor(self, rhs: Self) -> Self {
self.union(rhs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LegacyStatus {
#[default]
None,
ReducedNetworkFee,
Nachtspeicher,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum CapRelief {
#[default]
None,
ImsysWithControl,
DirektvermarktungFernsteuerbar,
}
impl CapRelief {
#[must_use]
pub const fn lifts_cap(self) -> bool {
!matches!(self, CapRelief::None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum SteuVeExemption {
PublicChargePoint,
EmergencyServices,
NonResidentialHeatingOrCooling,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AssetMeta {
pub id: AssetId,
#[cfg_attr(feature = "serde", serde(default))]
pub label: String,
pub circuit: CircuitId,
pub phases: PhaseConnection,
pub connection_power: Power,
#[cfg_attr(feature = "serde", serde(default))]
pub commissioned_at: Option<Date>,
#[cfg_attr(feature = "serde", serde(default))]
pub steuve_exemption: Option<SteuVeExemption>,
#[cfg_attr(feature = "serde", serde(default))]
pub legacy_status: LegacyStatus,
#[cfg_attr(feature = "serde", serde(default))]
pub switched_voluntarily: bool,
pub capabilities: Capabilities,
}
impl AssetMeta {
#[must_use]
pub fn new(
id: AssetId,
circuit: CircuitId,
phases: PhaseConnection,
connection_power: Power,
) -> Self {
Self {
label: id.to_string(),
id,
circuit,
phases,
connection_power,
commissioned_at: None,
steuve_exemption: None,
legacy_status: LegacyStatus::None,
switched_voluntarily: false,
capabilities: Capabilities::MEASURE,
}
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
self.capabilities = capabilities;
self
}
#[must_use]
pub fn commissioned(mut self, date: Date) -> Self {
self.commissioned_at = Some(date);
self
}
#[must_use]
pub fn exempt(mut self, reason: SteuVeExemption) -> Self {
self.steuve_exemption = Some(reason);
self
}
#[must_use]
pub fn with_legacy_status(mut self, legacy_status: LegacyStatus) -> Self {
self.legacy_status = legacy_status;
self
}
#[must_use]
pub fn switched_voluntarily(mut self) -> Self {
self.switched_voluntarily = true;
self
}
}
pub use metering::para14a::SteuVeFallgruppe as Fallgruppe;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum HeatPumpControl {
SgReady,
PowerCeiling,
OperationModes,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PvArray {
pub meta: AssetMeta,
pub kwp_dc: Power,
pub ac_nominal: Power,
#[cfg_attr(feature = "serde", serde(default))]
pub tilt_deg: f64,
#[cfg_attr(feature = "serde", serde(default))]
pub azimuth_deg: f64,
#[cfg_attr(feature = "serde", serde(default))]
pub cap_relief: CapRelief,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Chemistry {
#[default]
Lfp,
Nmc,
Other,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Battery {
pub meta: AssetMeta,
pub capacity: Energy,
pub max_charge: Power,
pub max_discharge: Power,
pub efficiency_charge: f64,
pub efficiency_discharge: f64,
pub soc_min: Soc,
pub soc_max: Soc,
#[cfg_attr(feature = "serde", serde(default))]
pub reserve_soc: Soc,
#[cfg_attr(feature = "serde", serde(default))]
pub chemistry: Chemistry,
#[cfg_attr(feature = "serde", serde(default))]
pub grid_charging_allowed: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Evse {
pub meta: AssetMeta,
pub min_current: Current,
pub max_current: Current,
#[cfg_attr(feature = "serde", serde(default))]
pub bidirectional: bool,
#[cfg_attr(feature = "serde", serde(default))]
pub public: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HeatPump {
pub meta: AssetMeta,
pub electrical_nominal: Power,
#[cfg_attr(feature = "serde", serde(default))]
pub heating_rod: Option<Power>,
pub control: HeatPumpControl,
#[cfg_attr(feature = "serde", serde(default))]
pub modulating: bool,
}
impl Evse {
#[must_use]
pub fn phase_count(&self, mode: PhaseMode) -> u8 {
self.meta.phases.count(mode).max(1)
}
#[must_use]
pub fn min_power(&self, mode: PhaseMode) -> Power {
Power::new(
self.min_current.get() * NOMINAL_VOLTAGE.get() * f64::from(self.phase_count(mode)),
)
}
#[must_use]
pub fn max_power(&self, mode: PhaseMode) -> Power {
Power::new(
self.max_current.get() * NOMINAL_VOLTAGE.get() * f64::from(self.phase_count(mode)),
)
.min(self.meta.connection_power.abs())
}
#[must_use]
pub fn lowest_useful_power(&self) -> Power {
[PhaseMode::Single, PhaseMode::Three]
.into_iter()
.filter(|m| self.meta.phases.supports(*m))
.map(|m| self.min_power(m))
.reduce(Power::min)
.unwrap_or_else(|| self.min_power(self.meta.phases.default_mode()))
}
#[must_use]
pub fn highest_power(&self) -> Power {
[PhaseMode::Single, PhaseMode::Three]
.into_iter()
.filter(|m| self.meta.phases.supports(*m))
.map(|m| self.max_power(m))
.reduce(Power::max)
.unwrap_or_else(|| self.max_power(self.meta.phases.default_mode()))
}
}
impl Battery {
#[must_use]
pub fn discharge_floor(&self) -> Soc {
if self.reserve_soc > self.soc_min {
self.reserve_soc
} else {
self.soc_min
}
}
}
impl HeatPump {
#[must_use]
pub fn group_power(&self) -> Power {
self.electrical_nominal + self.heating_rod.unwrap_or(Power::ZERO)
}
}
pub const WATER_KWH_PER_LITRE_KELVIN: f64 = 4.186 / 3600.0;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DhwTank {
pub meta: AssetMeta,
pub volume_l: f64,
pub heater: Power,
#[cfg_attr(feature = "serde", serde(default = "one"))]
pub cop: f64,
#[cfg_attr(feature = "serde", serde(default))]
pub standing_loss: Power,
pub t_min_c: f64,
pub t_set_c: f64,
pub t_max_c: f64,
}
fn one() -> f64 {
1.0
}
impl DhwTank {
#[must_use]
pub fn heat_capacity_kwh_per_k(&self) -> f64 {
self.volume_l.max(0.0) * WATER_KWH_PER_LITRE_KELVIN
}
#[must_use]
pub fn usable_heat(&self) -> Energy {
Energy::from_kwh(self.heat_capacity_kwh_per_k() * (self.t_max_c - self.t_min_c).max(0.0))
}
#[must_use]
pub fn stored_heat(&self, temperature_c: f64) -> Energy {
let above = (temperature_c - self.t_min_c).max(0.0);
Energy::from_kwh(self.heat_capacity_kwh_per_k() * above).min(self.usable_heat())
}
#[must_use]
pub fn temperature_at(&self, stored: Energy) -> f64 {
let c = self.heat_capacity_kwh_per_k();
if c <= 0.0 {
return self.t_min_c;
}
self.t_min_c + stored.kwh() / c
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum LoadKind {
#[default]
Fixed,
Shiftable,
Interruptible,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FlexibleLoad {
pub meta: AssetMeta,
pub nominal: Power,
pub kind: LoadKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum MeterRole {
GridConnection,
Production,
Submeter,
Consumption,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Meter {
pub meta: AssetMeta,
pub role: MeterRole,
#[cfg_attr(feature = "serde", serde(default))]
pub subject: Option<AssetId>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Relay {
pub meta: AssetMeta,
pub purpose: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case", tag = "type"))]
pub enum Asset {
Pv(PvArray),
Battery(Battery),
Evse(Evse),
HeatPump(HeatPump),
Dhw(DhwTank),
Load(FlexibleLoad),
Meter(Meter),
Relay(Relay),
}
impl Asset {
#[must_use]
pub fn meta(&self) -> &AssetMeta {
match self {
Asset::Pv(a) => &a.meta,
Asset::Battery(a) => &a.meta,
Asset::Evse(a) => &a.meta,
Asset::HeatPump(a) => &a.meta,
Asset::Dhw(a) => &a.meta,
Asset::Load(a) => &a.meta,
Asset::Meter(a) => &a.meta,
Asset::Relay(a) => &a.meta,
}
}
#[must_use]
pub fn id(&self) -> &AssetId {
&self.meta().id
}
#[must_use]
pub fn capabilities(&self) -> Capabilities {
self.meta().capabilities
}
#[must_use]
pub fn fallgruppe(&self) -> Option<Fallgruppe> {
match self {
Asset::Evse(e) if !e.public => Some(Fallgruppe::Ladepunkt),
Asset::HeatPump(_) => Some(Fallgruppe::Waermepumpe),
Asset::Battery(_) => Some(Fallgruppe::Stromspeicher),
_ => None,
}
}
#[must_use]
pub fn symmetry_relevant(&self) -> bool {
matches!(self, Asset::Pv(_) | Asset::Battery(_) | Asset::Evse(_))
}
#[must_use]
pub fn steuve_power(&self) -> Power {
match self {
Asset::HeatPump(hp) => hp.group_power(),
other => other.meta().connection_power,
}
}
#[must_use]
pub fn ratings(&self) -> Envelope {
match self {
Asset::Pv(pv) => Envelope::new(-pv.ac_nominal.max(Power::ZERO), Power::ZERO),
Asset::Battery(b) => {
Envelope::new(-b.max_discharge.abs(), b.max_charge.max(Power::ZERO))
}
Asset::Evse(e) => {
let ceiling = e.highest_power();
Envelope::new(
if e.bidirectional {
-ceiling
} else {
Power::ZERO
},
ceiling,
)
}
Asset::HeatPump(hp) => Envelope::new(Power::ZERO, hp.group_power()),
Asset::Dhw(t) => Envelope::new(Power::ZERO, t.heater.max(Power::ZERO)),
Asset::Load(l) => Envelope::new(Power::ZERO, l.meta.connection_power.max(Power::ZERO)),
Asset::Relay(r) => Envelope::new(Power::ZERO, r.meta.connection_power.max(Power::ZERO)),
Asset::Meter(m) => {
let p = m.meta.connection_power.abs();
Envelope::new(-p, p)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::units::Phase;
fn meta(id: &str, kw: f64) -> AssetMeta {
AssetMeta::new(
AssetId::new(id).unwrap(),
CircuitId::new("main").unwrap(),
PhaseConnection::Three,
Power::from_kw(kw),
)
}
#[test]
fn a_heat_pumps_steuve_power_includes_its_heating_rod() {
let hp = HeatPump {
meta: meta("wp", 5.0),
electrical_nominal: Power::from_kw(5.0),
heating_rod: Some(Power::from_kw(6.0)),
control: HeatPumpControl::PowerCeiling,
modulating: true,
};
assert_eq!(Asset::HeatPump(hp).steuve_power(), Power::from_kw(11.0));
}
#[test]
fn a_public_charge_point_is_not_a_fallgruppe() {
let mk = |public| {
Asset::Evse(Evse {
meta: meta("wallbox", 11.0),
min_current: Current::new(6.0),
max_current: Current::new(16.0),
bidirectional: false,
public,
})
};
assert_eq!(mk(false).fallgruppe(), Some(Fallgruppe::Ladepunkt));
assert_eq!(mk(true).fallgruppe(), None);
}
#[test]
fn ratings_are_asymmetric_because_hardware_is() {
let pv = Asset::Pv(PvArray {
meta: meta("pv", 9.8),
kwp_dc: Power::from_kw(9.8),
ac_nominal: Power::from_kw(8.0),
tilt_deg: 35.0,
azimuth_deg: 180.0,
cap_relief: CapRelief::None,
});
assert_eq!(pv.ratings().ceiling, Power::ZERO);
assert_eq!(pv.ratings().floor, Power::from_kw(-8.0));
let wallbox = Asset::Evse(Evse {
meta: meta("wallbox", 11.0),
min_current: Current::new(6.0),
max_current: Current::new(16.0),
bidirectional: false,
public: false,
});
assert_eq!(wallbox.ratings().floor, Power::ZERO);
assert!((wallbox.ratings().ceiling.kw() - 11.0).abs() < 1e-9);
}
#[test]
fn a_batterys_backup_reserve_beats_its_operating_floor() {
let b = Battery {
meta: meta("battery", 5.0),
capacity: Energy::from_kwh(10.0),
max_charge: Power::from_kw(5.0),
max_discharge: Power::from_kw(4.0),
efficiency_charge: 0.95,
efficiency_discharge: 0.95,
soc_min: Soc::new(0.05).unwrap(),
soc_max: Soc::new(0.95).unwrap(),
reserve_soc: Soc::new(0.30).unwrap(),
chemistry: Chemistry::Lfp,
grid_charging_allowed: true,
};
assert_eq!(b.discharge_floor(), Soc::new(0.30).unwrap());
let ratings = Asset::Battery(b).ratings();
assert_eq!(ratings.floor, Power::from_kw(-4.0));
assert_eq!(ratings.ceiling, Power::from_kw(5.0));
}
#[test]
fn capabilities_compose_and_answer_questions() {
let caps = Capabilities::MEASURE | Capabilities::LIMIT_CONSUMPTION | Capabilities::IDENTIFY;
assert!(caps.contains(Capabilities::LIMIT_CONSUMPTION));
assert!(!caps.contains(Capabilities::BIDIRECTIONAL));
assert!(caps.contains(Capabilities::MEASURE | Capabilities::IDENTIFY));
}
#[test]
fn a_single_phase_asset_names_its_conductor() {
let m = AssetMeta::new(
AssetId::new("heizstab").unwrap(),
CircuitId::new("main").unwrap(),
PhaseConnection::Single { phase: Phase::L2 },
Power::from_kw(3.0),
);
assert_eq!(m.phases.count(PhaseMode::Three), 1, "it cannot be switched");
assert_eq!(m.phases.single_phase_conductor(), Some(Phase::L2));
}
#[test]
fn a_switchable_charge_point_has_two_minimums_and_the_lower_one_matters() {
let mut m = meta("wallbox", 11.0);
m.phases = PhaseConnection::Switchable { phase: Phase::L1 };
let e = Evse {
meta: m,
min_current: Current::new(6.0),
max_current: Current::new(16.0),
bidirectional: false,
public: false,
};
assert!((e.min_power(PhaseMode::Three).kw() - 4.14).abs() < 1e-9);
assert!((e.min_power(PhaseMode::Single).kw() - 1.38).abs() < 1e-9);
assert_eq!(e.lowest_useful_power(), e.min_power(PhaseMode::Single));
assert_eq!(e.highest_power(), e.max_power(PhaseMode::Three));
let mut m = meta("fixed", 11.0);
m.phases = PhaseConnection::Three;
let fixed = Evse { meta: m, ..e };
assert_eq!(
fixed.lowest_useful_power(),
fixed.min_power(PhaseMode::Three)
);
}
}