1use serde::{Deserialize, Serialize};
26
27#[derive(Debug, thiserror::Error)]
33pub enum DermsError {
34 #[error("no DER assets registered")]
36 NoAssets,
37
38 #[error("load forecast length {got} does not match forecast horizon {expected}")]
40 ForecastLengthMismatch { got: usize, expected: usize },
41
42 #[error("infeasible dispatch: {0}")]
44 Infeasible(String),
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TariffStructure {
54 pub energy_rate_usd_per_mwh: Vec<f64>,
56 pub demand_charge_usd_per_mw: f64,
58 pub export_rate_usd_per_mwh: f64,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct GridLimits {
69 pub substation_capacity_mw: f64,
71 pub voltage_min_pu: f64,
73 pub voltage_max_pu: f64,
75 pub line_ratings_mw: Vec<f64>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum DermsObjective {
86 MinimizePeakDemand,
88 MaximizeSelfConsumption,
90 MinimizeCost {
92 tariff_structure: TariffStructure,
94 },
95 MaximizeRevenueFromGrid,
97 MinimizeLosses,
99 VoltageRegulation,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum DerAssetType {
110 RooftopSolar,
112 BatteryStorage,
114 ElectricVehicle,
116 HeatPump,
118 CombinedHeatPower,
120 DemandResponse,
122 SmartInverter,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct DerAsset {
129 pub id: usize,
131 pub asset_type: DerAssetType,
133 pub bus: usize,
135 pub p_max_mw: f64,
137 pub p_min_mw: f64,
139 pub q_max_mvar: f64,
141 pub q_min_mvar: f64,
143 pub forecast_mw: Vec<f64>,
145 pub current_p_mw: f64,
147 pub current_soc: Option<f64>,
149}
150
151impl DerAsset {
152 fn is_flexible(&self) -> bool {
154 !matches!(self.asset_type, DerAssetType::RooftopSolar)
155 }
156
157 fn flexible_range_at_hour(&self, hour: usize) -> (f64, f64) {
159 let base = self
160 .forecast_mw
161 .get(hour)
162 .copied()
163 .unwrap_or(self.current_p_mw);
164 match self.asset_type {
165 DerAssetType::DemandResponse => {
166 (base - self.p_max_mw, base)
168 }
169 DerAssetType::HeatPump => {
170 (base * 0.5, base)
172 }
173 DerAssetType::ElectricVehicle => {
174 (self.p_min_mw, self.p_max_mw)
176 }
177 DerAssetType::BatteryStorage => (self.p_min_mw, self.p_max_mw),
178 DerAssetType::CombinedHeatPower => (self.p_min_mw, self.p_max_mw),
179 DerAssetType::SmartInverter => (self.p_min_mw, self.p_max_mw),
180 _ => (base, base), }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct DermsDispatch {
192 pub asset_id: usize,
194 pub p_setpoint_mw: f64,
196 pub q_setpoint_mvar: f64,
198 pub reason: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct DermsConfig {
209 pub update_interval_s: f64,
211 pub forecast_horizon_h: usize,
213 pub grid_limits: GridLimits,
215 pub objectives: Vec<DermsObjective>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct DermsResult {
226 pub dispatch: Vec<DermsDispatch>,
228 pub peak_demand_mw: f64,
230 pub self_consumption_pct: f64,
232 pub estimated_cost_usd: f64,
234 pub voltage_violations: usize,
236 pub line_overloads: usize,
238 pub total_renewable_mwh: f64,
240 pub curtailment_mwh: f64,
242}
243
244pub struct DermsController {
250 config: DermsConfig,
251 assets: Vec<DerAsset>,
252}
253
254impl DermsController {
255 pub fn new(config: DermsConfig) -> Self {
257 Self {
258 config,
259 assets: Vec::new(),
260 }
261 }
262
263 pub fn register_asset(&mut self, asset: DerAsset) {
265 self.assets.push(asset);
266 }
267
268 pub fn dispatch(&self, load_forecast_mw: &[f64]) -> Result<DermsResult, DermsError> {
272 if self.assets.is_empty() {
273 return Err(DermsError::NoAssets);
274 }
275 let horizon = self.config.forecast_horizon_h;
276 if load_forecast_mw.len() != horizon {
277 return Err(DermsError::ForecastLengthMismatch {
278 got: load_forecast_mw.len(),
279 expected: horizon,
280 });
281 }
282
283 let mut solar_gen_mw = vec![0.0_f64; horizon];
287 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 {
291 match asset.asset_type {
292 DerAssetType::RooftopSolar | DerAssetType::SmartInverter => {
293 for (h, slot) in solar_gen_mw.iter_mut().enumerate().take(horizon) {
294 let gen = asset.forecast_mw.get(h).copied().unwrap_or(0.0).max(0.0);
295 *slot += gen;
296 }
297 }
298 _ if asset.is_flexible() => {
299 for (h, (slot_max, slot_min)) in flexible_max_mw
300 .iter_mut()
301 .zip(flexible_min_mw.iter_mut())
302 .enumerate()
303 .take(horizon)
304 {
305 let (fmin, fmax) = asset.flexible_range_at_hour(h);
306 let forecast = asset.forecast_mw.get(h).copied().unwrap_or(0.0);
307 *slot_max += (fmax - forecast).max(0.0);
311 let reduction = if fmin < 0.0 {
315 (-fmin).max(0.0) } else {
317 (forecast - fmin).max(0.0) };
319 *slot_min += reduction;
320 }
321 }
322 _ => {}
323 }
324 }
325
326 let net_load_mw: Vec<f64> = (0..horizon)
330 .map(|h| load_forecast_mw[h] - solar_gen_mw[h])
331 .collect();
332
333 let sub_cap = self.config.grid_limits.substation_capacity_mw;
337 let mut dispatched_mw = vec![0.0_f64; horizon]; let mut voltage_violations = 0_usize;
339 let mut line_overloads = 0_usize;
340 let mut total_cost = 0.0_f64;
341 let mut curtailment_mwh = 0.0_f64;
342
343 for h in 0..horizon {
344 let net = net_load_mw[h];
345 let import = net - dispatched_mw[h];
346
347 for obj in &self.config.objectives {
350 if let DermsObjective::MinimizePeakDemand = obj {
351 if import > sub_cap {
352 let reduction_needed = import - sub_cap;
354 let reduction = reduction_needed.min(flexible_min_mw[h]);
355 dispatched_mw[h] += reduction; }
357 }
358 if let DermsObjective::MaximizeSelfConsumption = obj {
359 if net < 0.0 {
361 let surplus = net.abs();
362 let absorbable = flexible_max_mw[h];
363 dispatched_mw[h] -= surplus.min(absorbable);
365 }
366 }
367 if let DermsObjective::MinimizeCost { tariff_structure } = obj {
368 let rate = tariff_structure
369 .energy_rate_usd_per_mwh
370 .get(h)
371 .copied()
372 .unwrap_or(60.0);
373 let final_import = (net - dispatched_mw[h]).max(0.0);
374 total_cost += final_import * rate * 1.0; }
376 }
377
378 let final_import = net - dispatched_mw[h];
380 if final_import > sub_cap + 1e-6 {
381 let overflow = final_import - sub_cap;
383 curtailment_mwh += overflow.min(solar_gen_mw[h]);
384 }
385
386 for &rating in &self.config.grid_limits.line_ratings_mw {
388 if final_import.abs() > rating + 1e-6 {
389 line_overloads += 1;
390 }
391 }
392
393 let import_fraction = final_import / sub_cap.max(1.0);
396 let v_proxy = 1.0 - 0.05 * import_fraction + 0.02 * solar_gen_mw[h] / sub_cap.max(1.0);
397 if v_proxy < self.config.grid_limits.voltage_min_pu
398 || v_proxy > self.config.grid_limits.voltage_max_pu
399 {
400 voltage_violations += 1;
401 }
402 }
403
404 let mut dispatch_setpoints = Vec::new();
408 let adjustment_h0 = dispatched_mw.first().copied().unwrap_or(0.0);
409 let n_flexible = self
410 .assets
411 .iter()
412 .filter(|a| a.is_flexible())
413 .count()
414 .max(1) as f64;
415
416 for asset in &self.assets {
417 let forecast_h0 = asset.forecast_mw.first().copied().unwrap_or(0.0);
418 let setpoint = if asset.is_flexible() {
419 let individual_adj = adjustment_h0 / n_flexible;
420 let (pmin, pmax) = asset.flexible_range_at_hour(0);
421 (forecast_h0 + individual_adj).clamp(pmin, pmax)
422 } else {
423 forecast_h0 };
425
426 let reason = match asset.asset_type {
427 DerAssetType::RooftopSolar => "solar at forecast".into(),
428 DerAssetType::BatteryStorage => {
429 if setpoint > forecast_h0 {
430 "charging for self-consumption or peak shaving".into()
431 } else {
432 "discharging to serve load".into()
433 }
434 }
435 DerAssetType::DemandResponse => "demand response activated".into(),
436 DerAssetType::ElectricVehicle => "EV smart charging".into(),
437 DerAssetType::HeatPump => "heat pump load management".into(),
438 DerAssetType::CombinedHeatPower => "CHP dispatch".into(),
439 DerAssetType::SmartInverter => "smart inverter volt-var control".into(),
440 };
441
442 dispatch_setpoints.push(DermsDispatch {
443 asset_id: asset.id,
444 p_setpoint_mw: setpoint,
445 q_setpoint_mvar: 0.0, reason,
447 });
448 }
449
450 let peak_demand_mw = net_load_mw
454 .iter()
455 .zip(dispatched_mw.iter())
456 .map(|(&nl, &disp)| (nl - disp).max(0.0))
457 .fold(f64::NEG_INFINITY, f64::max);
458
459 let total_solar_mwh: f64 = solar_gen_mw.iter().sum();
460 let self_consumption_pct = if total_solar_mwh > 1e-6 {
461 let exported: f64 = net_load_mw
462 .iter()
463 .zip(dispatched_mw.iter())
464 .map(|(&nl, &disp)| {
465 let net_import = nl - disp;
466 if net_import < 0.0 {
467 net_import.abs()
468 } else {
469 0.0
470 }
471 })
472 .sum();
473 ((total_solar_mwh - exported) / total_solar_mwh).clamp(0.0, 1.0)
474 } else {
475 0.0
476 };
477
478 Ok(DermsResult {
479 dispatch: dispatch_setpoints,
480 peak_demand_mw: peak_demand_mw.max(0.0),
481 self_consumption_pct,
482 estimated_cost_usd: total_cost,
483 voltage_violations,
484 line_overloads,
485 total_renewable_mwh: total_solar_mwh,
486 curtailment_mwh,
487 })
488 }
489}
490
491#[cfg(test)]
496mod tests {
497 use super::*;
498
499 fn default_grid_limits() -> GridLimits {
500 GridLimits {
501 substation_capacity_mw: 5.0,
502 voltage_min_pu: 0.95,
503 voltage_max_pu: 1.05,
504 line_ratings_mw: vec![6.0, 4.0],
505 }
506 }
507
508 fn default_config(horizon: usize) -> DermsConfig {
509 DermsConfig {
510 update_interval_s: 300.0,
511 forecast_horizon_h: horizon,
512 grid_limits: default_grid_limits(),
513 objectives: vec![DermsObjective::MinimizePeakDemand],
514 }
515 }
516
517 #[test]
518 fn test_peak_shaving_never_exceeds_substation() {
519 let mut ctrl = DermsController::new(default_config(4));
520
521 ctrl.register_asset(DerAsset {
523 id: 1,
524 asset_type: DerAssetType::BatteryStorage,
525 bus: 1,
526 p_max_mw: 3.0,
527 p_min_mw: -3.0,
528 q_max_mvar: 1.0,
529 q_min_mvar: -1.0,
530 forecast_mw: vec![-3.0; 4], current_p_mw: -3.0,
532 current_soc: Some(0.8),
533 });
534
535 ctrl.register_asset(DerAsset {
537 id: 2,
538 asset_type: DerAssetType::DemandResponse,
539 bus: 2,
540 p_max_mw: 2.0,
541 p_min_mw: 0.0,
542 q_max_mvar: 0.0,
543 q_min_mvar: 0.0,
544 forecast_mw: vec![4.0; 4],
545 current_p_mw: 4.0,
546 current_soc: None,
547 });
548
549 let load = vec![8.0; 4];
551 let result = ctrl.dispatch(&load).expect("dispatch should succeed");
552
553 assert!(
556 result.peak_demand_mw < 8.0,
557 "Peak shaving should reduce peak below 8 MW, got {:.2}",
558 result.peak_demand_mw
559 );
560 }
561
562 #[test]
563 fn test_self_consumption_maximises_solar_use() {
564 let cfg = DermsConfig {
565 update_interval_s: 300.0,
566 forecast_horizon_h: 4,
567 grid_limits: default_grid_limits(),
568 objectives: vec![DermsObjective::MaximizeSelfConsumption],
569 };
570 let mut ctrl = DermsController::new(cfg);
571
572 ctrl.register_asset(DerAsset {
574 id: 10,
575 asset_type: DerAssetType::RooftopSolar,
576 bus: 1,
577 p_max_mw: 3.0,
578 p_min_mw: 0.0,
579 q_max_mvar: 0.0,
580 q_min_mvar: 0.0,
581 forecast_mw: vec![3.0; 4],
582 current_p_mw: 3.0,
583 current_soc: None,
584 });
585
586 ctrl.register_asset(DerAsset {
588 id: 11,
589 asset_type: DerAssetType::BatteryStorage,
590 bus: 1,
591 p_max_mw: 2.0,
592 p_min_mw: -2.0,
593 q_max_mvar: 0.5,
594 q_min_mvar: -0.5,
595 forecast_mw: vec![0.0; 4],
596 current_p_mw: 0.0,
597 current_soc: Some(0.3),
598 });
599
600 let load = vec![2.0; 4];
602 let result = ctrl.dispatch(&load).expect("dispatch should succeed");
603
604 assert!(
606 result.total_renewable_mwh > 0.0,
607 "Should have solar generation"
608 );
609 assert!(
611 result.self_consumption_pct >= 0.0,
612 "Self-consumption fraction should be non-negative"
613 );
614 }
615
616 #[test]
617 fn test_cost_minimization_uses_tariff() {
618 let tariff = TariffStructure {
619 energy_rate_usd_per_mwh: vec![30.0, 30.0, 120.0, 120.0], demand_charge_usd_per_mw: 10.0,
621 export_rate_usd_per_mwh: 10.0,
622 };
623 let cfg = DermsConfig {
624 update_interval_s: 300.0,
625 forecast_horizon_h: 4,
626 grid_limits: default_grid_limits(),
627 objectives: vec![DermsObjective::MinimizeCost {
628 tariff_structure: tariff,
629 }],
630 };
631 let mut ctrl = DermsController::new(cfg);
632
633 ctrl.register_asset(DerAsset {
634 id: 20,
635 asset_type: DerAssetType::BatteryStorage,
636 bus: 1,
637 p_max_mw: 2.0,
638 p_min_mw: -2.0,
639 q_max_mvar: 0.5,
640 q_min_mvar: -0.5,
641 forecast_mw: vec![0.0; 4],
642 current_p_mw: 0.0,
643 current_soc: Some(0.5),
644 });
645
646 let load = vec![3.0; 4];
647 let result = ctrl.dispatch(&load).expect("dispatch should succeed");
648
649 assert!(
651 result.estimated_cost_usd >= 0.0,
652 "Cost should be non-negative"
653 );
654 }
655
656 #[test]
657 fn test_line_overloads_detected() {
658 let cfg = DermsConfig {
659 update_interval_s: 300.0,
660 forecast_horizon_h: 2,
661 grid_limits: GridLimits {
662 substation_capacity_mw: 20.0,
663 voltage_min_pu: 0.95,
664 voltage_max_pu: 1.05,
665 line_ratings_mw: vec![1.0], },
667 objectives: vec![DermsObjective::MinimizePeakDemand],
668 };
669 let mut ctrl = DermsController::new(cfg);
670
671 ctrl.register_asset(DerAsset {
672 id: 30,
673 asset_type: DerAssetType::CombinedHeatPower,
674 bus: 2,
675 p_max_mw: 5.0,
676 p_min_mw: 0.0,
677 q_max_mvar: 1.0,
678 q_min_mvar: 0.0,
679 forecast_mw: vec![5.0; 2],
680 current_p_mw: 5.0,
681 current_soc: None,
682 });
683
684 let load = vec![10.0; 2];
686 let result = ctrl.dispatch(&load).expect("dispatch should succeed");
687 assert!(
688 result.line_overloads > 0,
689 "Should detect line overloads with 10 MW through a 1 MW line"
690 );
691 }
692
693 #[test]
694 fn test_asset_registration_and_dispatch_ids() {
695 let mut ctrl = DermsController::new(default_config(3));
696
697 let asset_ids = [101_usize, 202, 303];
698 for &id in &asset_ids {
699 ctrl.register_asset(DerAsset {
700 id,
701 asset_type: DerAssetType::DemandResponse,
702 bus: id,
703 p_max_mw: 1.0,
704 p_min_mw: 0.0,
705 q_max_mvar: 0.0,
706 q_min_mvar: 0.0,
707 forecast_mw: vec![1.0; 3],
708 current_p_mw: 1.0,
709 current_soc: None,
710 });
711 }
712
713 let result = ctrl
714 .dispatch(&[2.0, 2.0, 2.0])
715 .expect("dispatch should succeed");
716
717 assert_eq!(result.dispatch.len(), asset_ids.len());
719 let returned_ids: Vec<usize> = result.dispatch.iter().map(|d| d.asset_id).collect();
720 for &id in &asset_ids {
721 assert!(
722 returned_ids.contains(&id),
723 "Asset {id} should have dispatch entry"
724 );
725 }
726 }
727
728 #[test]
729 fn test_no_assets_returns_error() {
730 let ctrl = DermsController::new(default_config(4));
731 let result = ctrl.dispatch(&[3.0; 4]);
732 assert!(
733 matches!(result, Err(DermsError::NoAssets)),
734 "Should return NoAssets error"
735 );
736 }
737
738 #[test]
739 fn test_ev_smart_charging_dispatch() {
740 let mut ctrl = DermsController::new(default_config(6));
741
742 ctrl.register_asset(DerAsset {
744 id: 50,
745 asset_type: DerAssetType::ElectricVehicle,
746 bus: 3,
747 p_max_mw: 4.0,
748 p_min_mw: 0.0,
749 q_max_mvar: 0.0,
750 q_min_mvar: 0.0,
751 forecast_mw: vec![2.0; 6],
752 current_p_mw: 2.0,
753 current_soc: Some(0.4),
754 });
755
756 let load = vec![3.0; 6];
757 let result = ctrl.dispatch(&load).expect("dispatch should succeed");
758
759 let ev_dispatch = result
760 .dispatch
761 .iter()
762 .find(|d| d.asset_id == 50)
763 .expect("EV should have dispatch entry");
764
765 assert!(ev_dispatch.p_setpoint_mw >= 0.0 - 1e-6);
767 assert!(ev_dispatch.p_setpoint_mw <= 4.0 + 1e-6);
768 }
769}