use crate::error::{OxiGridError, Result};
use crate::network::topology::PowerNetwork;
use crate::powerflow::{PowerFlowConfig, PowerFlowMethod};
use nalgebra::{DMatrix, DVector};
use serde::{Deserialize, Serialize};
use super::dc_opf::GenCost;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcOpfConfig {
pub max_iter: usize,
pub tolerance: f64,
pub enforce_voltage_limits: bool,
pub enforce_flow_limits: bool,
pub nr_max_iter: usize,
pub nr_tolerance: f64,
pub alpha: f64,
}
impl Default for AcOpfConfig {
fn default() -> Self {
Self {
max_iter: 50,
tolerance: 1e-6,
enforce_voltage_limits: true,
enforce_flow_limits: false,
nr_max_iter: 50,
nr_tolerance: 1e-8,
alpha: 1.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcOpfResult {
pub p_gen_mw: Vec<f64>,
pub q_gen_mvar: Vec<f64>,
pub voltage_magnitudes: Vec<f64>,
pub voltage_angles: Vec<f64>,
pub total_cost: f64,
pub lambda: f64,
pub converged: bool,
pub iterations: usize,
pub kkt_residual: f64,
pub max_mismatch: f64,
}
impl AcOpfResult {
pub fn active_power_loss_mw(&self, network: &PowerNetwork) -> f64 {
let p_gen: f64 = self.p_gen_mw.iter().sum();
let p_load: f64 = network.buses.iter().map(|b| b.pd.0).sum();
(p_gen - p_load).max(0.0)
}
}
pub fn solve_ac_opf(
network: &PowerNetwork,
gen_costs: &[GenCost],
config: &AcOpfConfig,
) -> Result<AcOpfResult> {
let n_gen = network.generators.len();
if gen_costs.len() != n_gen {
return Err(OxiGridError::InvalidParameter(format!(
"gen_costs length {} != generators length {}",
gen_costs.len(),
n_gen
)));
}
if n_gen == 0 {
return Err(OxiGridError::InvalidNetwork(
"No generators in network".into(),
));
}
let total_load_mw: f64 = network.buses.iter().map(|b| b.pd.0).sum();
let p_dispatch_init = economic_dispatch_internal(gen_costs, total_load_mw)?;
let mut net = network.clone();
set_generator_dispatch(&mut net, &p_dispatch_init);
let pf_config = PowerFlowConfig {
method: PowerFlowMethod::NewtonRaphson,
max_iter: config.nr_max_iter,
tolerance: config.nr_tolerance,
enforce_q_limits: false,
};
let mut pf_result = net.solve_powerflow(&pf_config)?;
if !pf_result.converged {
return Err(OxiGridError::Convergence {
iterations: config.nr_max_iter,
residual: pf_result.max_mismatch,
});
}
let mut p_gen: Vec<f64> = p_dispatch_init;
let mut q_gen: Vec<f64> = estimate_q_gen(&net, &pf_result.q_injected);
let mut iterations = 0;
let mut converged = false;
let mut kkt_residual = f64::INFINITY;
for iter in 0..config.max_iter {
iterations = iter + 1;
let grad_cost: Vec<f64> = gen_costs
.iter()
.zip(p_gen.iter())
.map(|(c, &p)| c.marginal_cost(p))
.collect();
let lambda_est = if n_gen > 0 {
grad_cost.iter().copied().sum::<f64>() / n_gen as f64
} else {
0.0
};
let grad_lagrangian: Vec<f64> = grad_cost
.iter()
.zip(p_gen.iter())
.zip(gen_costs.iter())
.map(|((&gc, &p), c)| {
let at_lb = p <= c.p_min + 1e-8;
let at_ub = p >= c.p_max - 1e-8;
if (at_lb && gc < lambda_est) || (at_ub && gc > lambda_est) {
0.0
} else {
gc - lambda_est
}
})
.collect();
kkt_residual = grad_lagrangian
.iter()
.map(|g| g.abs())
.fold(0.0_f64, f64::max);
if kkt_residual < config.tolerance {
converged = true;
break;
}
let alpha = config.alpha / (iter as f64 * 0.1 + 1.0);
let p_new: Vec<f64> = p_gen
.iter()
.zip(grad_lagrangian.iter())
.zip(gen_costs.iter())
.map(|((&p, &dg), c)| (p - alpha * dg).clamp(c.p_min, c.p_max))
.collect();
let p_total: f64 = p_new.iter().sum();
let p_deviation = p_total - total_load_mw;
let p_gen_next = project_to_balance(&p_new, gen_costs, p_deviation, total_load_mw);
let mut net2 = network.clone();
set_generator_dispatch(&mut net2, &p_gen_next);
match net2.solve_powerflow(&pf_config) {
Ok(pf) if pf.converged => {
p_gen = p_gen_next;
q_gen = estimate_q_gen(&net, &pf.q_injected);
pf_result = pf;
}
_ => {
let p_half: Vec<f64> = p_gen
.iter()
.zip(p_gen_next.iter())
.map(|(&old, &new)| (old + new) / 2.0)
.collect();
let mut net3 = network.clone();
set_generator_dispatch(&mut net3, &p_half);
if let Ok(pf) = net3.solve_powerflow(&pf_config) {
if pf.converged {
p_gen = p_half;
q_gen = estimate_q_gen(&net, &pf.q_injected);
pf_result = pf;
}
}
}
}
if config.enforce_voltage_limits {
let violations = voltage_violations(network, &pf_result.voltage_magnitude);
if !violations.is_empty() {
correct_voltage_violations(
&mut net,
&violations,
&pf_result.voltage_magnitude,
&mut q_gen,
network,
);
}
}
}
let total_cost: f64 = gen_costs
.iter()
.zip(p_gen.iter())
.map(|(c, &p)| c.total_cost(p))
.sum();
let lambda = {
let unconstrained: Vec<_> = p_gen
.iter()
.zip(gen_costs.iter())
.filter(|(&p, c)| p > c.p_min + 1e-4 && p < c.p_max - 1e-4)
.collect();
if unconstrained.is_empty() {
gen_costs
.iter()
.zip(p_gen.iter())
.map(|(c, &p)| c.marginal_cost(p))
.fold(0.0_f64, f64::max)
} else {
unconstrained
.iter()
.map(|(&p, c)| c.marginal_cost(p))
.sum::<f64>()
/ unconstrained.len() as f64
}
};
Ok(AcOpfResult {
p_gen_mw: p_gen,
q_gen_mvar: q_gen,
voltage_magnitudes: pf_result.voltage_magnitude,
voltage_angles: pf_result.voltage_angle,
total_cost,
lambda,
converged,
iterations,
kkt_residual,
max_mismatch: pf_result.max_mismatch,
})
}
fn set_generator_dispatch(network: &mut PowerNetwork, p_dispatch_mw: &[f64]) {
for (gen, &p_mw) in network.generators.iter_mut().zip(p_dispatch_mw.iter()) {
gen.pg = p_mw / network.base_mva;
}
}
fn project_to_balance(p: &[f64], costs: &[GenCost], p_deviation: f64, total_load: f64) -> Vec<f64> {
let mut result = p.to_vec();
if p_deviation.abs() < 1e-9 {
return result;
}
let (headroom_up, headroom_dn): (Vec<f64>, Vec<f64>) = costs
.iter()
.zip(p.iter())
.map(|(c, &pi)| (c.p_max - pi, pi - c.p_min))
.unzip();
let total_headroom = if p_deviation > 0.0 {
headroom_dn.iter().sum::<f64>() } else {
headroom_up.iter().sum::<f64>() };
if total_headroom < 1e-9 {
let delta = -p_deviation / costs.len() as f64;
for (i, c) in costs.iter().enumerate() {
result[i] = (result[i] + delta).clamp(c.p_min, c.p_max);
}
let actual: f64 = result.iter().sum();
let scale = total_load / actual.max(1e-9);
for (i, c) in costs.iter().enumerate() {
result[i] = (result[i] * scale).clamp(c.p_min, c.p_max);
}
} else {
for (i, c) in costs.iter().enumerate() {
let hr = if p_deviation > 0.0 {
headroom_dn[i]
} else {
headroom_up[i]
};
let frac = hr / total_headroom;
result[i] = (result[i] - p_deviation * frac).clamp(c.p_min, c.p_max);
}
}
result
}
fn economic_dispatch_internal(costs: &[GenCost], total_load_mw: f64) -> Result<Vec<f64>> {
let p_min_total: f64 = costs.iter().map(|c| c.p_min).sum();
let p_max_total: f64 = costs.iter().map(|c| c.p_max).sum();
if total_load_mw < p_min_total - 1e-6 {
return Err(OxiGridError::InvalidParameter(format!(
"Load {:.1} MW below minimum generation {:.1} MW",
total_load_mw, p_min_total
)));
}
if total_load_mw > p_max_total + 1e-6 {
return Err(OxiGridError::InvalidParameter(format!(
"Load {:.1} MW exceeds maximum generation {:.1} MW",
total_load_mw, p_max_total
)));
}
if costs.iter().all(|c| c.c.abs() < 1e-12) {
let mut order: Vec<usize> = (0..costs.len()).collect();
order.sort_by(|&a, &b| {
costs[a]
.b
.partial_cmp(&costs[b].b)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut p = costs.iter().map(|c| c.p_min).collect::<Vec<_>>();
let mut remaining = total_load_mw - p_min_total;
for &i in &order {
let headroom = costs[i].p_max - costs[i].p_min;
let added = remaining.min(headroom);
p[i] += added;
remaining -= added;
if remaining <= 1e-6 {
break;
}
}
return Ok(p);
}
let b_min = costs.iter().map(|c| c.b).fold(f64::INFINITY, f64::min);
let b_max = costs
.iter()
.map(|c| c.b + 2.0 * c.c * c.p_max)
.fold(f64::NEG_INFINITY, f64::max);
let mut lo = b_min;
let mut hi = b_max + 1.0;
let dispatch_at = |lam: f64| -> Vec<f64> {
costs
.iter()
.map(|c| {
if c.c.abs() < 1e-12 {
if lam >= c.b {
c.p_max
} else {
c.p_min
}
} else {
((lam - c.b) / (2.0 * c.c)).clamp(c.p_min, c.p_max)
}
})
.collect()
};
for _ in 0..100 {
let mid = (lo + hi) / 2.0;
let sum: f64 = dispatch_at(mid).iter().sum();
if sum < total_load_mw {
lo = mid;
} else {
hi = mid;
}
if (hi - lo) < 1e-9 {
break;
}
}
Ok(dispatch_at((lo + hi) / 2.0))
}
fn voltage_violations(_network: &PowerNetwork, voltages: &[f64]) -> Vec<(usize, f64, f64)> {
let v_max_default = 1.05;
let v_min_default = 0.95;
voltages
.iter()
.enumerate()
.filter_map(|(i, &vm)| {
if vm > v_max_default + 1e-4 {
Some((i, vm, v_max_default))
} else if vm < v_min_default - 1e-4 {
Some((i, vm, v_min_default))
} else {
None
}
})
.collect()
}
fn correct_voltage_violations(
net: &mut PowerNetwork,
violations: &[(usize, f64, f64)],
_voltages: &[f64],
q_gen: &mut [f64],
network: &PowerNetwork,
) {
for &(bus_idx, vm, v_target) in violations {
for (gi, gen) in net.generators.iter_mut().enumerate() {
if gen.bus_id == bus_idx {
gen.vg = v_target;
if vm > v_target {
q_gen[gi] -= (vm - v_target) * 10.0; } else {
q_gen[gi] += (v_target - vm) * 10.0; }
q_gen[gi] =
q_gen[gi].clamp(gen.qmin / network.base_mva, gen.qmax / network.base_mva);
break;
}
}
}
}
fn estimate_q_gen(network: &PowerNetwork, q_injected: &[f64]) -> Vec<f64> {
network
.generators
.iter()
.map(|gen| {
if let Ok(bi) = network.bus_index(gen.bus_id) {
let q_inj = q_injected.get(bi).copied().unwrap_or(0.0);
let q_load = network
.buses
.get(bi)
.map(|b| b.qd.0 / network.base_mva)
.unwrap_or(0.0);
q_inj + q_load
} else {
gen.qg / network.base_mva
}
})
.collect()
}
pub fn gen_injection_matrix(network: &PowerNetwork) -> DMatrix<f64> {
let n_bus = network.bus_count();
let n_gen = network.generators.len();
let mut s = DMatrix::<f64>::zeros(n_bus, n_gen);
for (gj, gen) in network.generators.iter().enumerate() {
if let Ok(bi) = network.bus_index(gen.bus_id) {
s[(bi, gj)] = 1.0;
}
}
s
}
pub fn cost_gradient_bus(
network: &PowerNetwork,
gen_costs: &[GenCost],
p_gen: &[f64],
) -> DVector<f64> {
let n_bus = network.bus_count();
let s = gen_injection_matrix(network);
let grad_gen = DVector::from_iterator(
gen_costs.len(),
gen_costs
.iter()
.zip(p_gen.iter())
.map(|(c, &p)| c.marginal_cost(p)),
);
let mut grad_bus = DVector::zeros(n_bus);
for i in 0..n_bus {
for j in 0..gen_costs.len() {
grad_bus[i] += s[(i, j)] * grad_gen[j];
}
}
grad_bus
}
#[cfg(test)]
mod tests {
use super::*;
use crate::network::PowerNetwork;
fn ieee14_net_and_costs() -> Option<(PowerNetwork, Vec<GenCost>)> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ieee14.m");
let net = PowerNetwork::from_matpower(path).ok()?;
let costs: Vec<GenCost> = net
.generators
.iter()
.map(|g| GenCost::quadratic(0.0, 20.0, 0.05, g.pmin.max(0.0), g.pmax.max(10.0)))
.collect();
Some((net, costs))
}
#[test]
fn test_ac_opf_ieee14_converges() {
if let Some((net, costs)) = ieee14_net_and_costs() {
let config = AcOpfConfig::default();
let result = solve_ac_opf(&net, &costs, &config);
assert!(
result.is_ok(),
"AC-OPF should not error: {:?}",
result.err()
);
let r = result.unwrap();
assert!(r.total_cost > 0.0, "Total cost should be positive");
}
}
#[test]
fn test_ac_opf_generation_within_limits() {
if let Some((net, costs)) = ieee14_net_and_costs() {
let result = solve_ac_opf(&net, &costs, &AcOpfConfig::default()).unwrap();
for (i, (&p, c)) in result.p_gen_mw.iter().zip(costs.iter()).enumerate() {
assert!(
p >= c.p_min - 1e-3 && p <= c.p_max + 1e-3,
"Gen {i}: P={p:.2} outside [{}, {}]",
c.p_min,
c.p_max
);
}
}
}
#[test]
fn test_ac_opf_power_balance() {
if let Some((net, costs)) = ieee14_net_and_costs() {
let result = solve_ac_opf(&net, &costs, &AcOpfConfig::default()).unwrap();
let total_gen: f64 = result.p_gen_mw.iter().sum();
let total_load: f64 = net.buses.iter().map(|b| b.pd.0).sum();
assert!(
total_gen >= total_load - 1.0,
"Generation {total_gen:.2} < load {total_load:.2}"
);
}
}
#[test]
fn test_ac_opf_voltages_reasonable() {
if let Some((net, costs)) = ieee14_net_and_costs() {
let result = solve_ac_opf(&net, &costs, &AcOpfConfig::default()).unwrap();
for (i, &vm) in result.voltage_magnitudes.iter().enumerate() {
assert!(
vm > 0.5 && vm < 1.5,
"Bus {i} voltage {vm:.4} out of reasonable range"
);
}
}
}
#[test]
fn test_economic_dispatch_internal() {
let costs = vec![
GenCost::quadratic(0.0, 20.0, 0.05, 10.0, 100.0),
GenCost::quadratic(0.0, 30.0, 0.03, 20.0, 150.0),
];
let p = economic_dispatch_internal(&costs, 120.0).unwrap();
let total: f64 = p.iter().sum();
assert!((total - 120.0).abs() < 1e-3, "dispatch sum={total:.4}");
for (&pi, c) in p.iter().zip(costs.iter()) {
assert!(pi >= c.p_min - 1e-6 && pi <= c.p_max + 1e-6);
}
}
#[test]
fn test_project_to_balance() {
let costs = vec![
GenCost::linear(20.0, 0.0, 100.0),
GenCost::linear(30.0, 0.0, 100.0),
];
let p = vec![60.0, 70.0]; let result = project_to_balance(&p, &costs, 10.0, 120.0);
let total: f64 = result.iter().sum();
assert!((total - 120.0).abs() < 1e-6, "projected total={total:.4}");
}
#[test]
fn test_gen_injection_matrix_shape() {
if let Some((net, _)) = ieee14_net_and_costs() {
let s = gen_injection_matrix(&net);
assert_eq!(s.nrows(), net.bus_count());
assert_eq!(s.ncols(), net.generators.len());
for j in 0..s.ncols() {
let non_zero = (0..s.nrows()).filter(|&i| s[(i, j)].abs() > 1e-10).count();
assert_eq!(non_zero, 1, "Column {j} should have exactly 1 non-zero");
}
}
}
#[test]
fn test_ac_opf_lower_cost_than_uniform() {
if let Some((net, costs)) = ieee14_net_and_costs() {
let result = solve_ac_opf(&net, &costs, &AcOpfConfig::default()).unwrap();
assert!(result.total_cost.is_finite(), "Cost should be finite");
assert!(result.total_cost >= 0.0, "Cost should be non-negative");
}
}
}