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:
| Crate | Purpose |
|---|---|
eneros-agent | Agent trait, DispatchAgent base class, AgentContext |
eneros-powerflow | Load flow calculation (for validating network constraints) |
eneros-tool | Tool engine (register load forecasting and other tools) |
eneros-reasoning | Rule reasoning engine (for decision review) |
eneros-constraint | Constraint validation engine |
eneros-eventbus | Event 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 Type | Check Content |
|---|---|
| Generator limits | P_min ≤ P_setpoint ≤ P_max |
| Rate limit | ` |
| Voltage violation | Each bus voltage within the allowed range after load flow |
| Branch overload | Each branch loading rate < 100% after load flow |
| Reserve capacity | Spinning reserve ≥ Reserve_requirement |
| Frequency deviation | ACE 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:
| Metric | Expected | Description |
|---|---|---|
| Power balance | ` | Σ P_gen - P_load - P_loss |
| Generator limits | All P_min ≤ P ≤ P_max | No violations |
| Spinning reserve | Σ (P_max - P) ≥ Reserve | Satisfies the N-1 criterion |
| Equal incremental cost | All online generators have similar λ_i | Tolerance < 0.5 $/MWh |
| Dispatch cycle | Once every 5 minutes | tick_interval = 300s |
| AGC response | Triggered when frequency deviation > 0.05Hz | ACE reverse adjustment |
| Constraint validation | All commands pass the constraint engine | 0 rejections |
Next Steps
- Multi-Agent Collaboration — multiple Agents making collaborative decisions
- Physics-Constrained Decisions — detailed explanation of the constraint engine
- SCADA Data Acquisition Integration — real-time data-driven dispatch
- Plugin Development — package the dispatch algorithm as a distributable plugin