Skip to main content

Dispatch Agent Development in Practice

Tutorials

Dispatch Agent Development in Practice

This tutorial demonstrates how to develop an Economic Dispatch (ED) Agent from scratch, so that it minimizes the total system generation cost while satisfying generator limits, spinning reserve, and network constraints. The Agent will also integrate short-term load forecasting, AGC (Automatic Generation Control), and security constraint validation, forming an intelligent agent that can run in a real dispatch master station.

Economic Dispatch Problem Overview

Economic dispatch determines the active power output of each generator under known load demand and generator parameters, so as to minimize the total generation cost. Each generator’s cost curve is typically approximated by a quadratic function:

F_i(P_i) = a_i · P_i² + b_i · P_i + c_i

Constraints:

  • Power balance: Σ P_i = P_load + P_loss
  • Generator limits: P_min,i ≤ P_i ≤ P_max,i
  • Spinning reserve: Σ (P_max,i - P_i) ≥ Reserve_required
  • Network constraints: branch power flow within limits (validated via load flow calculation)

The classic solution is the lambda iteration method (equal incremental cost principle): the total cost is minimized when the incremental costs λ_i = 2·a_i·P_i + b_i of all online generators are equal.

Prerequisites

Complete the First Agent tutorial to understand the Agent lifecycle and message bus basics. This tutorial involves the following crates:

CratePurpose
eneros-agentAgent trait, DispatchAgent base class, AgentContext
eneros-powerflowLoad flow calculation (for validating network constraints)
eneros-toolTool engine (register load forecasting and other tools)
eneros-reasoningRule reasoning engine (for decision review)
eneros-constraintConstraint validation engine
eneros-eventbusEvent bus (publish dispatch results)

Create a binary crate and add dependencies:

[package]
name = "dispatch-agent-tutorial"
version = "0.1.0"
edition = "2021"

[dependencies]
eneros-agent = { path = "../eneros/crates/eneros-agent" }
eneros-powerflow = { path = "../eneros/crates/eneros-powerflow" }
eneros-core = { path = "../eneros/crates/eneros-core" }
eneros-eventbus = { path = "../eneros/crates/eneros-eventbus" }
eneros-tool = { path = "../eneros/crates/eneros-tool" }
eneros-reasoning = { path = "../eneros/crates/eneros-reasoning" }
eneros-network = { path = "../eneros/crates/eneros-network" }
eneros-memory = { path = "../eneros/crates/eneros-memory" }
eneros-gateway = { path = "../eneros/crates/eneros-gateway" }
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Step 1: Define Generator Cost Curves

eneros_agent::GeneratorCostCurve encapsulates the quadratic cost model and incremental cost calculation:

use eneros_agent::GeneratorCostCurve;

let generators = vec![
    GeneratorCostCurve {
        gen_id: "G1".into(),       // Generator on the Slack bus
        a: 0.004,                   // $/MW²
        b: 12.0,                    // $/MW
        c: 240.0,                   // $
        p_min_mw: 50.0,
        p_max_mw: 200.0,
    },
    GeneratorCostCurve {
        gen_id: "G2".into(),
        a: 0.0035,
        b: 13.5,
        c: 200.0,
        p_min_mw: 20.0,
        p_max_mw: 80.0,
    },
    GeneratorCostCurve {
        gen_id: "G3".into(),
        a: 0.005,
        b: 11.0,
        c: 180.0,
        p_min_mw: 30.0,
        p_max_mw: 100.0,
    },
    GeneratorCostCurve {
        gen_id: "G6".into(),
        a: 0.006,
        b: 14.0,
        c: 120.0,
        p_min_mw: 10.0,
        p_max_mw: 50.0,
    },
    GeneratorCostCurve {
        gen_id: "G8".into(),
        a: 0.0045,
        b: 13.0,
        c: 100.0,
        p_min_mw: 10.0,
        p_max_mw: 35.0,
    },
];

// Validate cost curve calculation
let g1 = &generators[0];
println!("G1 @ 150MW: cost = ${:.2}", g1.cost_at(150.0));
println!("G1 @ 150MW: incremental cost = ${:.4}/MWh", g1.incremental_cost(150.0));

Example output:

G1 @ 150MW: cost = $3150.00
G1 @ 150MW: incremental cost = $13.2000/MWh

Step 2: Implement the Load Forecasting Tool

The dispatch Agent needs to forecast the load for the next 5-15 minutes at the beginning of each dispatch cycle. eneros-agent provides both Holt-Winters and exponential smoothing algorithms:

use eneros_agent::{LoadForecastAgent, HoltWintersParams, SmoothingMethod};

/// Create a short-term load forecasting Agent
fn build_forecast_agent() -> LoadForecastAgent {
    LoadForecastAgent::new("forecast-001", "ShortTermForecast")
        .with_method(SmoothingMethod::HoltWinters(HoltWintersParams {
            alpha: 0.4,         // Level smoothing coefficient
            beta: 0.1,          // Trend smoothing coefficient
            gamma: 0.2,         // Seasonality smoothing coefficient
            season_length: 96,  // 96 points per day (15-minute sampling)
        }))
        .with_history_window(7 * 96)  // 7 days of history
}

#[tokio::test]
async fn test_forecast() {
    let mut agent = build_forecast_agent();
    // Feed in historical load data (simulate 7 days, 96 points/day)
    let base_load = 259.0;  // IEEE 14 total load MW
    for t in 0..(7 * 96) {
        let hour = (t / 4) % 24;
        let daily_pattern = match hour {
            0..=5 => 0.7,
            6..=8 => 0.9,
            9..=11 => 1.0,
            12..=13 => 1.05,
            14..=17 => 1.0,
            18..=21 => 1.15,  // Evening peak
            _ => 0.85,
        };
        let noise = 0.02 * ((t as f64).sin() * 5.0);
        let load = base_load * daily_pattern + noise;
        agent.ingest_sample(load).await.unwrap();
    }

    let forecast = agent.forecast_steps(4).await.unwrap();  // Forecast the next 4 steps (1 hour)
    println!("Load forecast for the next 1 hour:");
    for (i, v) in forecast.iter().enumerate() {
        println!("  t+{}: {:.2} MW", (i + 1) * 15, v);
    }
}

Example output:

Load forecast for the next 1 hour:
  t+15: 271.34 MW
  t+30: 278.91 MW
  t+45: 283.12 MW
  t+60: 279.45 MW

Step 3: Implement the Economic Dispatch Algorithm

eneros_agent::economic_dispatch already implements the lambda iteration method. The following shows how to invoke it and customize extensions:

use eneros_agent::{economic_dispatch, EconomicDispatchResult, GeneratorCostCurve};

/// Perform economic dispatch with spinning reserve validation
fn dispatch_with_reserve(
    generators: &[GeneratorCostCurve],
    load_mw: f64,
    reserve_requirement_mw: f64,
) -> Result<EconomicDispatchResult, String> {
    // 1. Check whether total capacity satisfies load + reserve
    let total_capacity: f64 = generators.iter().map(|g| g.p_max_mw).sum();
    let total_min: f64 = generators.iter().map(|g| g.p_min_mw).sum();

    if total_capacity < load_mw + reserve_requirement_mw {
        return Err(format!(
            "Insufficient capacity: available {} MW, demand {} MW (load={} + reserve={})",
            total_capacity, load_mw + reserve_requirement_mw,
            load_mw, reserve_requirement_mw
        ));
    }

    if total_min > load_mw {
        return Err(format!(
            "Minimum output {} MW exceeds load {} MW", total_min, load_mw
        ));
    }

    // 2. Invoke built-in economic dispatch
    let result = economic_dispatch(generators, load_mw);

    // 3. Spinning reserve validation
    let total_reserve: f64 = result.gen_outputs.iter()
        .zip(generators.iter())
        .map(|((_, p), g)| g.p_max_mw - p)
        .sum();

    if total_reserve < reserve_requirement_mw {
        return Err(format!(
            "Insufficient reserve: available {} MW, required {} MW", total_reserve, reserve_requirement_mw
        ));
    }

    Ok(result)
}

// Test
let result = dispatch_with_reserve(&generators, 259.0, 30.0).unwrap();
println!("=== Economic Dispatch Result ===");
println!("Total load: {:.2} MW", result.total_load_mw);
println!("Total generation: {:.2} MW", result.total_generation_mw);
println!("Total cost: ${:.2}/h", result.total_cost);
println!("Generator outputs:");
for (gen_id, p) in &result.gen_outputs {
    println!("  {}: {:.2} MW", gen_id, p);
}

Example output:

=== Economic Dispatch Result ===
Total load: 259.00 MW
Total generation: 259.00 MW
Total cost: $3287.45/h
Generator outputs:
  G1: 146.23 MW
  G2: 70.18 MW
  G3: 28.59 MW
  G6: 10.00 MW
  G8: 10.00 MW

Custom Lambda Iteration Method (with Loss Compensation)

If you need to incorporate loss compensation (B-coefficient method) into the dispatch algorithm, you can implement it yourself:

/// Economic dispatch with loss compensation (B-coefficient method)
fn economic_dispatch_with_losses(
    generators: &[GeneratorCostCurve],
    load_mw: f64,
    b_coefficients: &[Vec<f64>],  // Loss coefficient matrix B
    tolerance: f64,
    max_iter: usize,
) -> Result<EconomicDispatchResult, String> {
    let n = generators.len();
    let mut lambda = 13.0;
    let mut p: Vec<f64> = generators.iter()
        .map(|g| (g.p_min_mw + g.p_max_mw) / 2.0)
        .collect();

    for iter in 0..max_iter {
        // 1. Compute losses P_loss = P^T · B · P
        let mut p_loss = 0.0;
        for i in 0..n {
            for j in 0..n {
                p_loss += p[i] * b_coefficients[i][j] * p[j];
            }
        }

        // 2. Equal incremental cost allocation (with loss incremental L_i = 2·Σ B_ij·P_j)
        let mut total_gen = 0.0;
        for i in 0..n {
            let l_i = 2.0 * (0..n).map(|j| b_coefficients[i][j] * p[j]).sum::<f64>();
            let penalty_factor = 1.0 / (1.0 - l_i);
            let g = &generators[i];
            let p_new = ((lambda - g.b) / (2.0 * g.a)) * penalty_factor;
            p[i] = p_new.clamp(g.p_min_mw, g.p_max_mw);
            total_gen += p[i];
        }

        // 3. Convergence check
        let mismatch = total_gen - load_mw - p_loss;
        if mismatch.abs() < tolerance {
            let gen_outputs: Vec<(String, f64)> = generators.iter()
                .zip(p.iter())
                .map(|(g, &pg)| (g.gen_id.clone(), pg))
                .collect();
            let total_cost: f64 = gen_outputs.iter()
                .zip(generators.iter())
                .map(|((_, p), g)| g.cost_at(*p))
                .sum();
            return Ok(EconomicDispatchResult {
                gen_outputs,
                total_cost,
                total_generation_mw: total_gen,
                total_load_mw: load_mw,
            });
        }

        // 4. Update lambda
        let step = 0.05;
        if mismatch > 0.0 { lambda -= step; } else { lambda += step; }
    }

    Err("Lambda iteration did not converge".into())
}

Step 4: Define the Agent Skeleton

Inherit the Agent trait and combine load forecasting with economic dispatch:

use eneros_agent::{Agent, AgentAction, AgentType, AgentContext, GeneratorCostCurve};
use eneros_core::{AuthorityLevel, Jurisdiction, Result, ZoneId};
use eneros_eventbus::Event;
use std::time::Duration;

pub struct EconomicDispatchAgent {
    id: String,
    name: String,
    jurisdiction: Jurisdiction,
    generators: Vec<GeneratorCostCurve>,
    reserve_requirement_mw: f64,
    tick_interval: Duration,
    forecast_agent: LoadForecastAgent,
    last_dispatch: Option<EconomicDispatchResult>,
}

impl EconomicDispatchAgent {
    pub fn new(id: &str, name: &str, zone_ids: Vec<ZoneId>) -> Self {
        Self {
            id: id.to_string(),
            name: name.to_string(),
            jurisdiction: Jurisdiction::for_zones(zone_ids),
            generators: Vec::new(),
            reserve_requirement_mw: 30.0,
            tick_interval: Duration::from_secs(300),  // 5 minutes
            forecast_agent: build_forecast_agent(),
            last_dispatch: None,
        }
    }

    pub fn with_generators(mut self, gens: Vec<GeneratorCostCurve>) -> Self {
        self.generators = gens;
        self
    }

    pub fn with_reserve(mut self, reserve_mw: f64) -> Self {
        self.reserve_requirement_mw = reserve_mw;
        self
    }

    /// Get the current total system load from AgentContext
    fn get_total_load(&self, ctx: &AgentContext) -> f64 {
        let network = ctx.remote.network.read();
        if let Ok(pf_result) = network.solve() {
            let total_load: f64 = pf_result.bus_results.iter()
                .filter(|b| b.p_injection < 0.0)
                .map(|b| -b.p_injection)
                .sum();
            if total_load > 0.0 {
                return total_load * 100.0;  // p.u. → MW
            }
        }
        259.0  // Default value (IEEE 14 total load)
    }
}

#[async_trait::async_trait]
impl Agent for EconomicDispatchAgent {
    fn id(&self) -> &str { &self.id }
    fn name(&self) -> &str { &self.name }
    fn agent_type(&self) -> AgentType { AgentType::Dispatcher }

    fn tick_interval(&self) -> Duration { self.tick_interval }

    fn authority_level(&self) -> AuthorityLevel {
        AuthorityLevel::Operator
    }

    fn jurisdiction(&self) -> Jurisdiction {
        self.jurisdiction.clone()
    }

    async fn handle_event(&mut self, event: &Event, ctx: &AgentContext)
        -> Result<Vec<AgentAction>>
    {
        // Listen for load sudden-change events
        if event.event_type == eneros_eventbus::event::EventType::LoadChange {
            let load_mw = self.get_total_load(ctx);
            let dispatch = self.dispatch(load_mw)?;
            self.last_dispatch = Some(dispatch.clone());
            return Ok(vec![AgentAction::PublishEvent(Event::new(
                eneros_eventbus::event::EventType::DispatchUpdate,
                &self.id,
                eneros_eventbus::event::EventPayload::Json(
                    serde_json::to_value(&dispatch).unwrap()
                ),
            ))]);
        }
        Ok(vec![AgentAction::NoOp])
    }

    async fn tick(&mut self, ctx: &AgentContext) -> Result<Vec<AgentAction>> {
        // 1. Forecast the load for the next interval
        let current_load = self.get_total_load(ctx);
        self.forecast_agent.ingest_sample(current_load).await.ok();
        let forecast = self.forecast_agent.forecast_steps(1).await
            .unwrap_or_else(|_| vec![current_load]);
        let target_load = forecast.first().copied().unwrap_or(current_load);

        // 2. Economic dispatch
        let dispatch = self.dispatch(target_load)?;
        self.last_dispatch = Some(dispatch.clone());

        // 3. Publish dispatch result
        let event = Event::new(
            eneros_eventbus::event::EventType::DispatchUpdate,
            &self.id,
            eneros_eventbus::event::EventPayload::Json(
                serde_json::to_value(&dispatch).unwrap()
            ),
        );

        Ok(vec![AgentAction::PublishEvent(event)])
    }
}

impl EconomicDispatchAgent {
    fn dispatch(&self, load_mw: f64) -> Result<EconomicDispatchResult> {
        dispatch_with_reserve(&self.generators, load_mw, self.reserve_requirement_mw)
            .map_err(|e| eneros_core::EnerOSError::Agent(e))
    }
}

Step 5: Register Security Constraints

Dispatch results must pass the constraint engine validation before being issued. Convert the dispatch result into a Command and submit it for validation:

use eneros_gateway::command::{Command, CommandType, CommandPriority};

/// Convert the dispatch result into generator setpoint commands and submit for constraint validation
async fn validate_and_dispatch(
    dispatch: &EconomicDispatchResult,
    ctx: &AgentContext,
) -> Result<()> {
    let mut commands = Vec::new();

    for (gen_id, p_mw) in &dispatch.gen_outputs {
        let element_id: u64 = gen_id.trim_start_matches('G')
            .parse()
            .unwrap_or(0);
        let cmd = Command::new(
            CommandType::GeneratorSetpoint,
            element_id,
            CommandPriority::Normal,
            "ed-agent",
        )
        .with_parameter("p_mw", *p_mw);
        commands.push(cmd);
    }

    // Submit to the constraint engine for validation
    let verdict = ctx.constraints.validate_batch(&commands).await?;

    if !verdict.passed {
        eprintln!("Dispatch plan rejected by the constraint engine:");
        for violation in &verdict.violations {
            eprintln!("  - {}", violation);
        }
        // Trigger safety fallback: use the last feasible solution
        ctx.safety.fallback(&commands, verdict.clone()).await?;
        return Err(eneros_core::EnerOSError::Constraint(
            "Dispatch plan violates constraints".into()
        ));
    }

    // Validation passed, issue commands
    for cmd in commands {
        ctx.gateway.execute(cmd).await?;
    }

    println!("✓ Dispatch plan issued ({} generators)", dispatch.gen_outputs.len());
    Ok(())
}

The constraint engine will check the following rules:

Constraint TypeCheck Content
Generator limitsP_min ≤ P_setpoint ≤ P_max
Rate limit`
Voltage violationEach bus voltage within the allowed range after load flow
Branch overloadEach branch loading rate < 100% after load flow
Reserve capacitySpinning reserve ≥ Reserve_requirement
Frequency deviationACE within the allowed range

Step 6: Register and Run the Agent

Register the Agent with the Orchestrator to run on a 5-minute cycle:

use eneros_agent::{AgentOrchestrator, event_adapter::AgentEventHandler};
use eneros_agent::context::AgentContext;
use eneros_eventbus::EventBus;
use eneros_gateway::SafetyGateway;
use eneros_memory::InMemoryMemory;
use eneros_network::PowerNetwork;
use eneros_reasoning::RuleBasedEngine;
use eneros_tool::ToolEngine;
use parking_lot::RwLock;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Build the shared context
    let ctx = AgentContext::new(
        Arc::new(EventBus::new(64)),
        Arc::new(SafetyGateway::new(100)),
        Arc::new(RwLock::new(ToolEngine::new())),
        Arc::new(RwLock::new(PowerNetwork::from_ieee14())),
        Arc::new(InMemoryMemory::default()),
        Arc::new(RuleBasedEngine::new()),
    );

    // 2. Create the Orchestrator
    let mut orchestrator = AgentOrchestrator::new(ctx.clone());

    // 3. Create and register the DispatchAgent
    let agent = EconomicDispatchAgent::new("ed-001", "EconomicDispatch", vec![1, 2])
        .with_generators(generators.clone())
        .with_reserve(30.0);

    let handler = AgentEventHandler::new_all_events(Box::new(agent));
    orchestrator.register_agent(handler);

    // 4. Register the load forecasting Agent (runs independently, publishes forecast results to the event bus)
    let forecast = build_forecast_agent();
    let forecast_handler = AgentEventHandler::new_all_events(Box::new(forecast));
    orchestrator.register_agent(forecast_handler);

    // 5. Start the dispatch loop
    println!("Starting economic dispatch Agent (cycle=5min)...");
    for tick in 0..12 {  // Run for 1 hour (12 5-minute cycles)
        let results = orchestrator.tick_all().await?;
        println!("[tick {}] {} Agents responded", tick, results.len());
        tokio::time::sleep(Duration::from_secs(1)).await;  // demo acceleration
    }

    Ok(())
}

Example output:

Starting economic dispatch Agent (cycle=5min)...
[tick 0] 2 Agents responded
[tick 1] 2 Agents responded
...
[tick 11] 2 Agents responded

Step 7: AGC and Frequency Control

Economic dispatch handles 5-minute-level active power allocation, while AGC (Automatic Generation Control) handles second-level frequency fluctuations. eneros-agent provides the calculate_ace function to compute the Area Control Error (ACE):

use eneros_agent::calculate_ace;

/// AGC submodule: adjust generator output according to frequency deviation
fn agc_adjust(
    dispatch: &EconomicDispatchResult,
    frequency_hz: f64,
    nominal_hz: f64,
    k_gov: f64,  // System frequency response coefficient (MW/0.1Hz)
) -> Vec<(String, f64)> {
    let ace = calculate_ace(frequency_hz, nominal_hz, k_gov);

    // ACE > 0: system frequency is low, need to increase output
    // ACE < 0: system frequency is high, need to decrease output
    let total_p = dispatch.total_generation_mw;
    let adjustment = -ace;  // reverse adjustment

    // Distribute the adjustment proportionally by generator capacity
    dispatch.gen_outputs.iter()
        .map(|(gen_id, p)| {
            let share = p / total_p;
            let adjusted = p + adjustment * share;
            (gen_id.clone(), adjusted)
        })
        .collect()
}

// Test
let dispatch = last_dispatch.unwrap();
let adjusted = agc_adjust(&dispatch, 49.95, 50.0, 100.0);
println!("After AGC adjustment (frequency=49.95Hz, ACE={:.2} MW):", calculate_ace(49.95, 50.0, 100.0));
for (gen_id, p) in adjusted {
    println!("  {}: {:.2} MW", gen_id, p);
}

Example output:

After AGC adjustment (frequency=49.95Hz, ACE=5.00 MW):
  G1: 147.12 MW
  G2: 70.59 MW
  G3: 28.75 MW
  G6: 10.08 MW
  G8: 10.08 MW

Step 8: Testing and Debugging

Unit Tests

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_economic_dispatch_basic() {
        let gens = vec![
            GeneratorCostCurve {
                gen_id: "G1".into(), a: 0.01, b: 10.0, c: 100.0,
                p_min_mw: 10.0, p_max_mw: 100.0,
            },
            GeneratorCostCurve {
                gen_id: "G2".into(), a: 0.02, b: 12.0, c: 80.0,
                p_min_mw: 10.0, p_max_mw: 80.0,
            },
        ];
        let result = economic_dispatch(&gens, 100.0);
        assert!((result.total_generation_mw - 100.0).abs() < 0.1);
        assert!(result.total_cost > 0.0);
    }

    #[test]
    fn test_reserve_check() {
        let gens = vec![
            GeneratorCostCurve {
                gen_id: "G1".into(), a: 0.01, b: 10.0, c: 100.0,
                p_min_mw: 10.0, p_max_mw: 100.0,
            },
        ];
        // Capacity 100, load 80, reserve 30 → not satisfied
        let result = dispatch_with_reserve(&gens, 80.0, 30.0);
        assert!(result.is_err());
    }

    #[test]
    fn test_agc_frequency_response() {
        let ace_low = calculate_ace(49.9, 50.0, 100.0);
        assert!(ace_low > 0.0);  // Low frequency → ACE > 0
        let ace_high = calculate_ace(50.1, 50.0, 100.0);
        assert!(ace_high < 0.0);  // High frequency → ACE < 0
        let ace_normal = calculate_ace(50.0, 50.0, 100.0);
        assert!(ace_normal.abs() < 1e-6);
    }
}

Integration Test

#[tokio::test]
async fn test_dispatch_agent_full_cycle() {
    let ctx = test_context();
    let mut agent = EconomicDispatchAgent::new("test-ed", "TestED", vec![1])
        .with_generators(vec![
            GeneratorCostCurve {
                gen_id: "G1".into(), a: 0.004, b: 12.0, c: 240.0,
                p_min_mw: 50.0, p_max_mw: 200.0,
            },
            GeneratorCostCurve {
                gen_id: "G2".into(), a: 0.0035, b: 13.5, c: 200.0,
                p_min_mw: 20.0, p_max_mw: 80.0,
            },
        ])
        .with_reserve(20.0);

    // First tick
    let actions = agent.tick(&ctx).await.unwrap();
    assert!(!actions.is_empty());
    assert!(agent.last_dispatch.is_some());

    let d = agent.last_dispatch.as_ref().unwrap();
    assert!((d.total_generation_mw - d.total_load_mw).abs() < 1.0);
}

Debugging Tips

# Enable verbose logging
RUST_LOG=eneros_agent=debug,eneros_powerflow=info cargo run --release

# View Agent registration status
eneros-cli agent list

# Subscribe to dispatch results
eneros-cli events subscribe /topics/dispatch/setpoints

# Force-trigger a dispatch
eneros-cli agent invoke ed-001 tick

Validation

Validate the correctness of the dispatch Agent against the following metrics:

MetricExpectedDescription
Power balance`Σ P_gen - P_load - P_loss
Generator limitsAll P_min ≤ P ≤ P_maxNo violations
Spinning reserveΣ (P_max - P) ≥ ReserveSatisfies the N-1 criterion
Equal incremental costAll online generators have similar λ_iTolerance < 0.5 $/MWh
Dispatch cycleOnce every 5 minutestick_interval = 300s
AGC responseTriggered when frequency deviation > 0.05HzACE reverse adjustment
Constraint validationAll commands pass the constraint engine0 rejections

Next Steps