use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum DermsError {
#[error("no DER assets registered")]
NoAssets,
#[error("load forecast length {got} does not match forecast horizon {expected}")]
ForecastLengthMismatch { got: usize, expected: usize },
#[error("infeasible dispatch: {0}")]
Infeasible(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TariffStructure {
pub energy_rate_usd_per_mwh: Vec<f64>,
pub demand_charge_usd_per_mw: f64,
pub export_rate_usd_per_mwh: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GridLimits {
pub substation_capacity_mw: f64,
pub voltage_min_pu: f64,
pub voltage_max_pu: f64,
pub line_ratings_mw: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DermsObjective {
MinimizePeakDemand,
MaximizeSelfConsumption,
MinimizeCost {
tariff_structure: TariffStructure,
},
MaximizeRevenueFromGrid,
MinimizeLosses,
VoltageRegulation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum DerAssetType {
RooftopSolar,
BatteryStorage,
ElectricVehicle,
HeatPump,
CombinedHeatPower,
DemandResponse,
SmartInverter,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerAsset {
pub id: usize,
pub asset_type: DerAssetType,
pub bus: usize,
pub p_max_mw: f64,
pub p_min_mw: f64,
pub q_max_mvar: f64,
pub q_min_mvar: f64,
pub forecast_mw: Vec<f64>,
pub current_p_mw: f64,
pub current_soc: Option<f64>,
}
impl DerAsset {
fn is_flexible(&self) -> bool {
!matches!(self.asset_type, DerAssetType::RooftopSolar)
}
fn flexible_range_at_hour(&self, hour: usize) -> (f64, f64) {
let base = self
.forecast_mw
.get(hour)
.copied()
.unwrap_or(self.current_p_mw);
match self.asset_type {
DerAssetType::DemandResponse => {
(base - self.p_max_mw, base)
}
DerAssetType::HeatPump => {
(base * 0.5, base)
}
DerAssetType::ElectricVehicle => {
(self.p_min_mw, self.p_max_mw)
}
DerAssetType::BatteryStorage => (self.p_min_mw, self.p_max_mw),
DerAssetType::CombinedHeatPower => (self.p_min_mw, self.p_max_mw),
DerAssetType::SmartInverter => (self.p_min_mw, self.p_max_mw),
_ => (base, base), }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DermsDispatch {
pub asset_id: usize,
pub p_setpoint_mw: f64,
pub q_setpoint_mvar: f64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DermsConfig {
pub update_interval_s: f64,
pub forecast_horizon_h: usize,
pub grid_limits: GridLimits,
pub objectives: Vec<DermsObjective>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DermsResult {
pub dispatch: Vec<DermsDispatch>,
pub peak_demand_mw: f64,
pub self_consumption_pct: f64,
pub estimated_cost_usd: f64,
pub voltage_violations: usize,
pub line_overloads: usize,
pub total_renewable_mwh: f64,
pub curtailment_mwh: f64,
}
pub struct DermsController {
config: DermsConfig,
assets: Vec<DerAsset>,
}
impl DermsController {
pub fn new(config: DermsConfig) -> Self {
Self {
config,
assets: Vec::new(),
}
}
pub fn register_asset(&mut self, asset: DerAsset) {
self.assets.push(asset);
}
pub fn dispatch(&self, load_forecast_mw: &[f64]) -> Result<DermsResult, DermsError> {
if self.assets.is_empty() {
return Err(DermsError::NoAssets);
}
let horizon = self.config.forecast_horizon_h;
if load_forecast_mw.len() != horizon {
return Err(DermsError::ForecastLengthMismatch {
got: load_forecast_mw.len(),
expected: horizon,
});
}
let mut solar_gen_mw = vec![0.0_f64; horizon];
let mut flexible_max_mw = vec![0.0_f64; horizon]; let mut flexible_min_mw = vec![0.0_f64; horizon];
for asset in &self.assets {
match asset.asset_type {
DerAssetType::RooftopSolar | DerAssetType::SmartInverter => {
for (h, slot) in solar_gen_mw.iter_mut().enumerate().take(horizon) {
let gen = asset.forecast_mw.get(h).copied().unwrap_or(0.0).max(0.0);
*slot += gen;
}
}
_ if asset.is_flexible() => {
for (h, (slot_max, slot_min)) in flexible_max_mw
.iter_mut()
.zip(flexible_min_mw.iter_mut())
.enumerate()
.take(horizon)
{
let (fmin, fmax) = asset.flexible_range_at_hour(h);
let forecast = asset.forecast_mw.get(h).copied().unwrap_or(0.0);
*slot_max += (fmax - forecast).max(0.0);
let reduction = if fmin < 0.0 {
(-fmin).max(0.0) } else {
(forecast - fmin).max(0.0) };
*slot_min += reduction;
}
}
_ => {}
}
}
let net_load_mw: Vec<f64> = (0..horizon)
.map(|h| load_forecast_mw[h] - solar_gen_mw[h])
.collect();
let sub_cap = self.config.grid_limits.substation_capacity_mw;
let mut dispatched_mw = vec![0.0_f64; horizon]; let mut voltage_violations = 0_usize;
let mut line_overloads = 0_usize;
let mut total_cost = 0.0_f64;
let mut curtailment_mwh = 0.0_f64;
for h in 0..horizon {
let net = net_load_mw[h];
let import = net - dispatched_mw[h];
for obj in &self.config.objectives {
if let DermsObjective::MinimizePeakDemand = obj {
if import > sub_cap {
let reduction_needed = import - sub_cap;
let reduction = reduction_needed.min(flexible_min_mw[h]);
dispatched_mw[h] += reduction; }
}
if let DermsObjective::MaximizeSelfConsumption = obj {
if net < 0.0 {
let surplus = net.abs();
let absorbable = flexible_max_mw[h];
dispatched_mw[h] -= surplus.min(absorbable);
}
}
if let DermsObjective::MinimizeCost { tariff_structure } = obj {
let rate = tariff_structure
.energy_rate_usd_per_mwh
.get(h)
.copied()
.unwrap_or(60.0);
let final_import = (net - dispatched_mw[h]).max(0.0);
total_cost += final_import * rate * 1.0; }
}
let final_import = net - dispatched_mw[h];
if final_import > sub_cap + 1e-6 {
let overflow = final_import - sub_cap;
curtailment_mwh += overflow.min(solar_gen_mw[h]);
}
for &rating in &self.config.grid_limits.line_ratings_mw {
if final_import.abs() > rating + 1e-6 {
line_overloads += 1;
}
}
let import_fraction = final_import / sub_cap.max(1.0);
let v_proxy = 1.0 - 0.05 * import_fraction + 0.02 * solar_gen_mw[h] / sub_cap.max(1.0);
if v_proxy < self.config.grid_limits.voltage_min_pu
|| v_proxy > self.config.grid_limits.voltage_max_pu
{
voltage_violations += 1;
}
}
let mut dispatch_setpoints = Vec::new();
let adjustment_h0 = dispatched_mw.first().copied().unwrap_or(0.0);
let n_flexible = self
.assets
.iter()
.filter(|a| a.is_flexible())
.count()
.max(1) as f64;
for asset in &self.assets {
let forecast_h0 = asset.forecast_mw.first().copied().unwrap_or(0.0);
let setpoint = if asset.is_flexible() {
let individual_adj = adjustment_h0 / n_flexible;
let (pmin, pmax) = asset.flexible_range_at_hour(0);
(forecast_h0 + individual_adj).clamp(pmin, pmax)
} else {
forecast_h0 };
let reason = match asset.asset_type {
DerAssetType::RooftopSolar => "solar at forecast".into(),
DerAssetType::BatteryStorage => {
if setpoint > forecast_h0 {
"charging for self-consumption or peak shaving".into()
} else {
"discharging to serve load".into()
}
}
DerAssetType::DemandResponse => "demand response activated".into(),
DerAssetType::ElectricVehicle => "EV smart charging".into(),
DerAssetType::HeatPump => "heat pump load management".into(),
DerAssetType::CombinedHeatPower => "CHP dispatch".into(),
DerAssetType::SmartInverter => "smart inverter volt-var control".into(),
};
dispatch_setpoints.push(DermsDispatch {
asset_id: asset.id,
p_setpoint_mw: setpoint,
q_setpoint_mvar: 0.0, reason,
});
}
let peak_demand_mw = net_load_mw
.iter()
.zip(dispatched_mw.iter())
.map(|(&nl, &disp)| (nl - disp).max(0.0))
.fold(f64::NEG_INFINITY, f64::max);
let total_solar_mwh: f64 = solar_gen_mw.iter().sum();
let self_consumption_pct = if total_solar_mwh > 1e-6 {
let exported: f64 = net_load_mw
.iter()
.zip(dispatched_mw.iter())
.map(|(&nl, &disp)| {
let net_import = nl - disp;
if net_import < 0.0 {
net_import.abs()
} else {
0.0
}
})
.sum();
((total_solar_mwh - exported) / total_solar_mwh).clamp(0.0, 1.0)
} else {
0.0
};
Ok(DermsResult {
dispatch: dispatch_setpoints,
peak_demand_mw: peak_demand_mw.max(0.0),
self_consumption_pct,
estimated_cost_usd: total_cost,
voltage_violations,
line_overloads,
total_renewable_mwh: total_solar_mwh,
curtailment_mwh,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_grid_limits() -> GridLimits {
GridLimits {
substation_capacity_mw: 5.0,
voltage_min_pu: 0.95,
voltage_max_pu: 1.05,
line_ratings_mw: vec![6.0, 4.0],
}
}
fn default_config(horizon: usize) -> DermsConfig {
DermsConfig {
update_interval_s: 300.0,
forecast_horizon_h: horizon,
grid_limits: default_grid_limits(),
objectives: vec![DermsObjective::MinimizePeakDemand],
}
}
#[test]
fn test_peak_shaving_never_exceeds_substation() {
let mut ctrl = DermsController::new(default_config(4));
ctrl.register_asset(DerAsset {
id: 1,
asset_type: DerAssetType::BatteryStorage,
bus: 1,
p_max_mw: 3.0,
p_min_mw: -3.0,
q_max_mvar: 1.0,
q_min_mvar: -1.0,
forecast_mw: vec![-3.0; 4], current_p_mw: -3.0,
current_soc: Some(0.8),
});
ctrl.register_asset(DerAsset {
id: 2,
asset_type: DerAssetType::DemandResponse,
bus: 2,
p_max_mw: 2.0,
p_min_mw: 0.0,
q_max_mvar: 0.0,
q_min_mvar: 0.0,
forecast_mw: vec![4.0; 4],
current_p_mw: 4.0,
current_soc: None,
});
let load = vec![8.0; 4];
let result = ctrl.dispatch(&load).expect("dispatch should succeed");
assert!(
result.peak_demand_mw < 8.0,
"Peak shaving should reduce peak below 8 MW, got {:.2}",
result.peak_demand_mw
);
}
#[test]
fn test_self_consumption_maximises_solar_use() {
let cfg = DermsConfig {
update_interval_s: 300.0,
forecast_horizon_h: 4,
grid_limits: default_grid_limits(),
objectives: vec![DermsObjective::MaximizeSelfConsumption],
};
let mut ctrl = DermsController::new(cfg);
ctrl.register_asset(DerAsset {
id: 10,
asset_type: DerAssetType::RooftopSolar,
bus: 1,
p_max_mw: 3.0,
p_min_mw: 0.0,
q_max_mvar: 0.0,
q_min_mvar: 0.0,
forecast_mw: vec![3.0; 4],
current_p_mw: 3.0,
current_soc: None,
});
ctrl.register_asset(DerAsset {
id: 11,
asset_type: DerAssetType::BatteryStorage,
bus: 1,
p_max_mw: 2.0,
p_min_mw: -2.0,
q_max_mvar: 0.5,
q_min_mvar: -0.5,
forecast_mw: vec![0.0; 4],
current_p_mw: 0.0,
current_soc: Some(0.3),
});
let load = vec![2.0; 4];
let result = ctrl.dispatch(&load).expect("dispatch should succeed");
assert!(
result.total_renewable_mwh > 0.0,
"Should have solar generation"
);
assert!(
result.self_consumption_pct >= 0.0,
"Self-consumption fraction should be non-negative"
);
}
#[test]
fn test_cost_minimization_uses_tariff() {
let tariff = TariffStructure {
energy_rate_usd_per_mwh: vec![30.0, 30.0, 120.0, 120.0], demand_charge_usd_per_mw: 10.0,
export_rate_usd_per_mwh: 10.0,
};
let cfg = DermsConfig {
update_interval_s: 300.0,
forecast_horizon_h: 4,
grid_limits: default_grid_limits(),
objectives: vec![DermsObjective::MinimizeCost {
tariff_structure: tariff,
}],
};
let mut ctrl = DermsController::new(cfg);
ctrl.register_asset(DerAsset {
id: 20,
asset_type: DerAssetType::BatteryStorage,
bus: 1,
p_max_mw: 2.0,
p_min_mw: -2.0,
q_max_mvar: 0.5,
q_min_mvar: -0.5,
forecast_mw: vec![0.0; 4],
current_p_mw: 0.0,
current_soc: Some(0.5),
});
let load = vec![3.0; 4];
let result = ctrl.dispatch(&load).expect("dispatch should succeed");
assert!(
result.estimated_cost_usd >= 0.0,
"Cost should be non-negative"
);
}
#[test]
fn test_line_overloads_detected() {
let cfg = DermsConfig {
update_interval_s: 300.0,
forecast_horizon_h: 2,
grid_limits: GridLimits {
substation_capacity_mw: 20.0,
voltage_min_pu: 0.95,
voltage_max_pu: 1.05,
line_ratings_mw: vec![1.0], },
objectives: vec![DermsObjective::MinimizePeakDemand],
};
let mut ctrl = DermsController::new(cfg);
ctrl.register_asset(DerAsset {
id: 30,
asset_type: DerAssetType::CombinedHeatPower,
bus: 2,
p_max_mw: 5.0,
p_min_mw: 0.0,
q_max_mvar: 1.0,
q_min_mvar: 0.0,
forecast_mw: vec![5.0; 2],
current_p_mw: 5.0,
current_soc: None,
});
let load = vec![10.0; 2];
let result = ctrl.dispatch(&load).expect("dispatch should succeed");
assert!(
result.line_overloads > 0,
"Should detect line overloads with 10 MW through a 1 MW line"
);
}
#[test]
fn test_asset_registration_and_dispatch_ids() {
let mut ctrl = DermsController::new(default_config(3));
let asset_ids = [101_usize, 202, 303];
for &id in &asset_ids {
ctrl.register_asset(DerAsset {
id,
asset_type: DerAssetType::DemandResponse,
bus: id,
p_max_mw: 1.0,
p_min_mw: 0.0,
q_max_mvar: 0.0,
q_min_mvar: 0.0,
forecast_mw: vec![1.0; 3],
current_p_mw: 1.0,
current_soc: None,
});
}
let result = ctrl
.dispatch(&[2.0, 2.0, 2.0])
.expect("dispatch should succeed");
assert_eq!(result.dispatch.len(), asset_ids.len());
let returned_ids: Vec<usize> = result.dispatch.iter().map(|d| d.asset_id).collect();
for &id in &asset_ids {
assert!(
returned_ids.contains(&id),
"Asset {id} should have dispatch entry"
);
}
}
#[test]
fn test_no_assets_returns_error() {
let ctrl = DermsController::new(default_config(4));
let result = ctrl.dispatch(&[3.0; 4]);
assert!(
matches!(result, Err(DermsError::NoAssets)),
"Should return NoAssets error"
);
}
#[test]
fn test_ev_smart_charging_dispatch() {
let mut ctrl = DermsController::new(default_config(6));
ctrl.register_asset(DerAsset {
id: 50,
asset_type: DerAssetType::ElectricVehicle,
bus: 3,
p_max_mw: 4.0,
p_min_mw: 0.0,
q_max_mvar: 0.0,
q_min_mvar: 0.0,
forecast_mw: vec![2.0; 6],
current_p_mw: 2.0,
current_soc: Some(0.4),
});
let load = vec![3.0; 6];
let result = ctrl.dispatch(&load).expect("dispatch should succeed");
let ev_dispatch = result
.dispatch
.iter()
.find(|d| d.asset_id == 50)
.expect("EV should have dispatch entry");
assert!(ev_dispatch.p_setpoint_mw >= 0.0 - 1e-6);
assert!(ev_dispatch.p_setpoint_mw <= 4.0 + 1e-6);
}
}