Skip to main content

oxigrid/optimize/vpp/
derms.rs

1//! Distributed Energy Resource Management System (DERMS).
2//!
3//! Coordinates heterogeneous DER assets — rooftop solar, battery storage,
4//! EVs, heat pumps, CHP, demand response, and smart inverters — to
5//! optimise multiple grid objectives simultaneously:
6//!
7//! - Peak demand shaving (substation capacity enforcement)
8//! - Self-consumption maximisation (solar-to-load matching)
9//! - Cost minimisation against time-of-use tariffs
10//! - Voltage regulation and line overload prevention
11//!
12//! # Dispatch algorithm
13//! 1. Aggregate all asset power forecasts per timestep.
14//! 2. Compute net load = total load − solar generation.
15//! 3. Dispatch flexible assets (storage, EV, DR) to satisfy objectives.
16//! 4. Check voltage and line-flow constraints; apply curtailment if violated.
17//! 5. Report dispatch setpoints, metrics, and constraint violations.
18//!
19//! # Units
20//! - Power: \[MW\] / \[Mvar\]
21//! - Voltage: \[pu\]
22//! - Cost: \[USD\]
23//! - Line ratings: \[MW\]
24
25use serde::{Deserialize, Serialize};
26
27// ---------------------------------------------------------------------------
28// Error type
29// ---------------------------------------------------------------------------
30
31/// Errors produced by the DERMS controller.
32#[derive(Debug, thiserror::Error)]
33pub enum DermsError {
34    /// No assets registered.
35    #[error("no DER assets registered")]
36    NoAssets,
37
38    /// Load forecast length mismatch.
39    #[error("load forecast length {got} does not match forecast horizon {expected}")]
40    ForecastLengthMismatch { got: usize, expected: usize },
41
42    /// Infeasible dispatch (e.g. load exceeds all flexible capacity).
43    #[error("infeasible dispatch: {0}")]
44    Infeasible(String),
45}
46
47// ---------------------------------------------------------------------------
48// Tariff structure
49// ---------------------------------------------------------------------------
50
51/// Time-of-use tariff parameters.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TariffStructure {
54    /// Energy charge per hour \[$/MWh\], length = forecast horizon
55    pub energy_rate_usd_per_mwh: Vec<f64>,
56    /// Peak demand charge per MW \[$/MW\]
57    pub demand_charge_usd_per_mw: f64,
58    /// Feed-in tariff for exported energy \[$/MWh\]
59    pub export_rate_usd_per_mwh: f64,
60}
61
62// ---------------------------------------------------------------------------
63// Grid limits
64// ---------------------------------------------------------------------------
65
66/// Physical limits of the distribution feeder / substation.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct GridLimits {
69    /// Substation import capacity \[MW\]
70    pub substation_capacity_mw: f64,
71    /// Minimum acceptable voltage \[pu\]
72    pub voltage_min_pu: f64,
73    /// Maximum acceptable voltage \[pu\]
74    pub voltage_max_pu: f64,
75    /// Per-segment line thermal rating \[MW\]
76    pub line_ratings_mw: Vec<f64>,
77}
78
79// ---------------------------------------------------------------------------
80// DERMS objectives
81// ---------------------------------------------------------------------------
82
83/// Optimisation objective for the DERMS controller.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum DermsObjective {
86    /// Minimise peak demand at the substation
87    MinimizePeakDemand,
88    /// Maximise solar self-consumption (minimise export and import)
89    MaximizeSelfConsumption,
90    /// Minimise electricity cost using time-of-use tariffs
91    MinimizeCost {
92        /// Applicable tariff structure
93        tariff_structure: TariffStructure,
94    },
95    /// Maximise revenue from grid export (e.g. arbitrage or ancillary services)
96    MaximizeRevenueFromGrid,
97    /// Minimise distribution losses (proxy: minimise net import²)
98    MinimizeLosses,
99    /// Regulate voltage to nominal (1.0 pu)
100    VoltageRegulation,
101}
102
103// ---------------------------------------------------------------------------
104// DER asset types
105// ---------------------------------------------------------------------------
106
107/// Classification of a DER asset.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum DerAssetType {
110    /// Rooftop photovoltaic system (generation asset)
111    RooftopSolar,
112    /// Battery energy storage system
113    BatteryStorage,
114    /// Electric vehicle (can charge, optionally V2G discharge)
115    ElectricVehicle,
116    /// Heat pump (controllable load)
117    HeatPump,
118    /// Combined heat and power unit (generation + heat)
119    CombinedHeatPower,
120    /// Demand response programme participant
121    DemandResponse,
122    /// Smart inverter (P/Q controllable)
123    SmartInverter,
124}
125
126/// A single distributed energy resource asset registered with the DERMS.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct DerAsset {
129    /// Unique asset identifier
130    pub id: usize,
131    /// Asset technology type
132    pub asset_type: DerAssetType,
133    /// Grid bus number this asset is connected to
134    pub bus: usize,
135    /// Maximum active power output / injection \[MW\]
136    pub p_max_mw: f64,
137    /// Minimum active power (negative = consumption minimum) \[MW\]
138    pub p_min_mw: f64,
139    /// Maximum reactive power injection \[Mvar\]
140    pub q_max_mvar: f64,
141    /// Minimum reactive power injection \[Mvar\]
142    pub q_min_mvar: f64,
143    /// Power forecast for the horizon \[MW\] (positive = generation)
144    pub forecast_mw: Vec<f64>,
145    /// Current measured active power \[MW\]
146    pub current_p_mw: f64,
147    /// State of charge for storage assets (0–1)
148    pub current_soc: Option<f64>,
149}
150
151impl DerAsset {
152    /// Returns `true` if this asset can be controlled (not uncontrolled solar).
153    fn is_flexible(&self) -> bool {
154        !matches!(self.asset_type, DerAssetType::RooftopSolar)
155    }
156
157    /// Available flexible power at a given hour.
158    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                // Can reduce load by up to p_max_mw
167                (base - self.p_max_mw, base)
168            }
169            DerAssetType::HeatPump => {
170                // Can curtail up to 50% or shift
171                (base * 0.5, base)
172            }
173            DerAssetType::ElectricVehicle => {
174                // Flexible charge schedule; optionally V2G
175                (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), // not flexible
181        }
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Dispatch result
187// ---------------------------------------------------------------------------
188
189/// A single dispatch setpoint for one asset.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct DermsDispatch {
192    /// Asset identifier
193    pub asset_id: usize,
194    /// Active power setpoint \[MW\] (positive = generation / discharge)
195    pub p_setpoint_mw: f64,
196    /// Reactive power setpoint \[Mvar\]
197    pub q_setpoint_mvar: f64,
198    /// Human-readable reason for the setpoint
199    pub reason: String,
200}
201
202// ---------------------------------------------------------------------------
203// DERMS configuration
204// ---------------------------------------------------------------------------
205
206/// Configuration for the DERMS controller.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct DermsConfig {
209    /// Control loop interval \[s\]
210    pub update_interval_s: f64,
211    /// Look-ahead forecast horizon \[h\]
212    pub forecast_horizon_h: usize,
213    /// Physical grid constraints
214    pub grid_limits: GridLimits,
215    /// List of active optimisation objectives (priority order)
216    pub objectives: Vec<DermsObjective>,
217}
218
219// ---------------------------------------------------------------------------
220// DERMS Result
221// ---------------------------------------------------------------------------
222
223/// Summary result from a DERMS dispatch round.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct DermsResult {
226    /// Per-asset dispatch setpoints (first timestep)
227    pub dispatch: Vec<DermsDispatch>,
228    /// Peak demand at substation \[MW\]
229    pub peak_demand_mw: f64,
230    /// Self-consumption of solar generation (fraction 0–1)
231    pub self_consumption_pct: f64,
232    /// Estimated electricity cost \[USD\]
233    pub estimated_cost_usd: f64,
234    /// Number of voltage constraint violations across the horizon
235    pub voltage_violations: usize,
236    /// Number of line overload incidents across the horizon
237    pub line_overloads: usize,
238    /// Total renewable energy served \[MWh\]
239    pub total_renewable_mwh: f64,
240    /// Renewable curtailment \[MWh\]
241    pub curtailment_mwh: f64,
242}
243
244// ---------------------------------------------------------------------------
245// DERMS Controller
246// ---------------------------------------------------------------------------
247
248/// Central controller for distributed energy resource management.
249pub struct DermsController {
250    config: DermsConfig,
251    assets: Vec<DerAsset>,
252}
253
254impl DermsController {
255    /// Create a new DERMS controller with the given configuration.
256    pub fn new(config: DermsConfig) -> Self {
257        Self {
258            config,
259            assets: Vec::new(),
260        }
261    }
262
263    /// Register a DER asset for DERMS control.
264    pub fn register_asset(&mut self, asset: DerAsset) {
265        self.assets.push(asset);
266    }
267
268    /// Run one dispatch cycle given a load forecast.
269    ///
270    /// Returns dispatch setpoints and summary metrics.
271    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        // ------------------------------------------------------------------
284        // Step 1: Aggregate asset forecasts per timestep
285        // ------------------------------------------------------------------
286        let mut solar_gen_mw = vec![0.0_f64; horizon];
287        let mut flexible_max_mw = vec![0.0_f64; horizon]; // max we can reduce load or inject
288        let mut flexible_min_mw = vec![0.0_f64; horizon]; // max we can increase load or absorb
289
290        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                        // Capacity to increase net injection (reduce grid import)
308                        // = max output - current forecast (for generators)
309                        // + |min setpoint - forecast| for load-reduction assets
310                        *slot_max += (fmax - forecast).max(0.0);
311                        // Reduction capacity: how much can we reduce grid import?
312                        // For generators (fmin < 0): discharge capacity = (-fmin).max(0)
313                        // For loads (fmin >= 0, fmin < forecast): sheddable = forecast - fmin
314                        let reduction = if fmin < 0.0 {
315                            (-fmin).max(0.0) // battery can inject (-fmin) MW
316                        } else {
317                            (forecast - fmin).max(0.0) // DR can shed this much
318                        };
319                        *slot_min += reduction;
320                    }
321                }
322                _ => {}
323            }
324        }
325
326        // ------------------------------------------------------------------
327        // Step 2: Compute net load = load - solar
328        // ------------------------------------------------------------------
329        let net_load_mw: Vec<f64> = (0..horizon)
330            .map(|h| load_forecast_mw[h] - solar_gen_mw[h])
331            .collect();
332
333        // ------------------------------------------------------------------
334        // Step 3 & 4: Dispatch flexible assets per objective
335        // ------------------------------------------------------------------
336        let sub_cap = self.config.grid_limits.substation_capacity_mw;
337        let mut dispatched_mw = vec![0.0_f64; horizon]; // net dispatch adjustment
338        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            // --- Objective: Peak shaving ---
348            // dispatched_mw[h] > 0 means "reduction in grid import achieved by flexible assets"
349            for obj in &self.config.objectives {
350                if let DermsObjective::MinimizePeakDemand = obj {
351                    if import > sub_cap {
352                        // Dispatch flexible assets (battery discharge / DR shed) to reduce import
353                        let reduction_needed = import - sub_cap;
354                        let reduction = reduction_needed.min(flexible_min_mw[h]);
355                        dispatched_mw[h] += reduction; // positive: reduces net import
356                    }
357                }
358                if let DermsObjective::MaximizeSelfConsumption = obj {
359                    // Surplus solar: charge storage or increase controllable loads
360                    if net < 0.0 {
361                        let surplus = net.abs();
362                        let absorbable = flexible_max_mw[h];
363                        // Absorbing surplus reduces export (negative net → import increases toward 0)
364                        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; // 1-hour timestep
375                }
376            }
377
378            // --- Constraint check: substation capacity ---
379            let final_import = net - dispatched_mw[h];
380            if final_import > sub_cap + 1e-6 {
381                // Still overloaded after flexible dispatch — curtail solar
382                let overflow = final_import - sub_cap;
383                curtailment_mwh += overflow.min(solar_gen_mw[h]);
384            }
385
386            // --- Constraint check: line ratings ---
387            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            // --- Simplified voltage check ---
394            // Proxy: if import >> capacity, voltage sags; if large export, voltage rises
395            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        // ------------------------------------------------------------------
405        // Step 5: Build dispatch for first timestep
406        // ------------------------------------------------------------------
407        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 // solar runs at forecast (curtailment handled above)
424            };
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, // simplified: no reactive dispatch
446                reason,
447            });
448        }
449
450        // ------------------------------------------------------------------
451        // Metrics
452        // ------------------------------------------------------------------
453        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// ---------------------------------------------------------------------------
492// Tests
493// ---------------------------------------------------------------------------
494
495#[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        // Battery storage: can discharge up to 3 MW
522        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], // discharging
531            current_p_mw: -3.0,
532            current_soc: Some(0.8),
533        });
534
535        // DR: can shed 2 MW
536        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        // Load forecast: 8 MW (well above 5 MW limit)
550        let load = vec![8.0; 4];
551        let result = ctrl.dispatch(&load).expect("dispatch should succeed");
552
553        // After peak shaving, peak demand should not dramatically exceed limit
554        // (may not be perfect with simplified model, but must be reduced from 8)
555        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        // Solar: 3 MW generation
573        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        // Battery: can absorb 2 MW
587        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        // Load: 2 MW → surplus solar = 1 MW
601        let load = vec![2.0; 4];
602        let result = ctrl.dispatch(&load).expect("dispatch should succeed");
603
604        // Self consumption should be > 0 (solar is available)
605        assert!(
606            result.total_renewable_mwh > 0.0,
607            "Should have solar generation"
608        );
609        // Some self-consumption achieved
610        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], // cheap then expensive
620            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        // Cost is computed and non-negative
650        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], // very tight: 1 MW only
666            },
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        // Load 10 MW through a 1 MW rated line → overloads
685        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        // Every registered asset should have a dispatch entry
718        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        // EV fleet: can charge 0–4 MW
743        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        // EV setpoint should respect its bounds
766        assert!(ev_dispatch.p_setpoint_mw >= 0.0 - 1e-6);
767        assert!(ev_dispatch.p_setpoint_mw <= 4.0 + 1e-6);
768    }
769}