use crate::error::{OxiGridError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmmGenerator {
pub bus: usize,
pub p_min_mw: f64,
pub p_max_mw: f64,
pub q_min_mvar: f64,
pub q_max_mvar: f64,
pub cost_a: f64,
pub cost_b: f64,
}
impl AdmmGenerator {
pub fn marginal_cost(&self, p: f64) -> f64 {
self.cost_b + 2.0 * self.cost_a * p
}
pub fn total_cost(&self, p: f64) -> f64 {
self.cost_a * p * p + self.cost_b * p
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmmArea {
pub id: usize,
pub bus_indices: Vec<usize>,
pub boundary_buses: Vec<usize>,
pub generators: Vec<AdmmGenerator>,
pub loads_mw: Vec<f64>,
pub loads_mvar: Vec<f64>,
}
impl AdmmArea {
pub fn total_load_mw(&self) -> f64 {
self.loads_mw.iter().sum()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoundaryLink {
pub bus_global: usize,
pub area_1: usize,
pub bus_in_area_1: usize,
pub area_2: usize,
pub bus_in_area_2: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmmConfig {
pub rho: f64,
pub max_iterations: usize,
pub primal_tol: f64,
pub dual_tol: f64,
pub rho_update_enabled: bool,
pub rho_scale_factor: f64,
pub base_mva: f64,
}
impl Default for AdmmConfig {
fn default() -> Self {
Self {
rho: 1.0,
max_iterations: 200,
primal_tol: 1e-4,
dual_tol: 1e-4,
rho_update_enabled: true,
rho_scale_factor: 2.0,
base_mva: 100.0,
}
}
}
#[derive(Debug, Clone)]
pub struct AdmmState {
pub x: Vec<Vec<f64>>,
pub z: Vec<f64>,
pub y: Vec<Vec<f64>>,
pub primal_residual: f64,
pub dual_residual: f64,
pub rho: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmmAreaDispatch {
pub area_id: usize,
pub generator_dispatch_mw: Vec<f64>,
pub generator_dispatch_mvar: Vec<f64>,
pub bus_voltages: Vec<f64>,
pub area_cost_per_h: f64,
pub import_export_mw: Vec<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmmResult {
pub converged: bool,
pub iterations: usize,
pub area_dispatches: Vec<AdmmAreaDispatch>,
pub boundary_voltages: Vec<f64>,
pub total_cost_per_h: f64,
pub primal_residual_history: Vec<f64>,
pub dual_residual_history: Vec<f64>,
pub convergence_rate: f64,
}
pub struct AdmmOpf {
pub areas: Vec<AdmmArea>,
pub boundary_links: Vec<BoundaryLink>,
pub config: AdmmConfig,
}
impl AdmmOpf {
pub fn new(
areas: Vec<AdmmArea>,
boundary_links: Vec<BoundaryLink>,
config: AdmmConfig,
) -> Self {
Self {
areas,
boundary_links,
config,
}
}
pub fn solve(&mut self) -> Result<AdmmResult> {
let n_areas = self.areas.len();
let n_boundary = self.boundary_links.len();
if n_areas == 0 {
return Err(OxiGridError::InvalidParameter(
"ADMM requires at least one area".to_string(),
));
}
let area_bmap = Self::build_area_boundary_map(n_areas, &self.boundary_links);
let n_vars: Vec<usize> = (0..n_areas)
.map(|a| self.areas[a].generators.len() + area_bmap[a].len())
.collect();
let x_init: Vec<Vec<f64>> = (0..n_areas)
.map(|a| {
let area = &self.areas[a];
let mut v = Vec::with_capacity(n_vars[a]);
for g in &area.generators {
v.push(0.5 * (g.p_min_mw + g.p_max_mw));
}
v.resize(v.len() + area_bmap[a].len(), 0.0);
v
})
.collect();
let z_init = vec![0.0_f64; n_boundary];
let y_init: Vec<Vec<f64>> = (0..n_areas)
.map(|a| vec![0.0_f64; area_bmap[a].len()])
.collect();
let mut state = AdmmState {
x: x_init,
z: z_init,
y: y_init,
primal_residual: f64::MAX,
dual_residual: f64::MAX,
rho: self.config.rho,
};
let mut primal_history: Vec<f64> = Vec::with_capacity(self.config.max_iterations);
let mut dual_history: Vec<f64> = Vec::with_capacity(self.config.max_iterations);
let mut converged = false;
let mut final_iter = 0_usize;
for iter in 0..self.config.max_iterations {
final_iter = iter + 1;
#[allow(clippy::needless_range_loop)]
for a in 0..n_areas {
let n_gen = self.areas[a].generators.len();
let bvars = &area_bmap[a];
let n_bvars = bvars.len();
let z_local: Vec<f64> = bvars.iter().map(|(li, _)| state.z[*li]).collect();
let y_local: Vec<f64> = state.y[a].clone();
let new_x = Self::x_update_area(
&self.areas[a],
&y_local,
&z_local,
state.rho,
n_gen,
n_bvars,
)?;
state.x[a] = new_x;
}
let z_old = state.z.clone();
state.z = Self::z_update(
&state.x,
&state.y,
&self.boundary_links,
&area_bmap,
n_areas,
n_boundary,
state.rho,
);
Self::y_update(
&mut state.y,
&state.x,
&state.z,
&self.boundary_links,
&area_bmap,
state.rho,
);
state.primal_residual =
Self::compute_primal_residual(&state.x, &state.z, &self.boundary_links, &area_bmap);
state.dual_residual = Self::compute_dual_residual(&z_old, &state.z, state.rho);
primal_history.push(state.primal_residual);
dual_history.push(state.dual_residual);
if state.primal_residual <= self.config.primal_tol
&& state.dual_residual <= self.config.dual_tol
{
converged = true;
break;
}
if self.config.rho_update_enabled {
Self::update_rho(
&mut state.rho,
state.primal_residual,
state.dual_residual,
self.config.rho_scale_factor,
&mut state.y,
);
}
}
self.build_result(
&state,
&area_bmap,
converged,
final_iter,
primal_history,
dual_history,
)
}
pub fn build_area_boundary_map(
n_areas: usize,
links: &[BoundaryLink],
) -> Vec<Vec<(usize, u8)>> {
let mut map: Vec<Vec<(usize, u8)>> = vec![Vec::new(); n_areas];
for (li, link) in links.iter().enumerate() {
if link.area_1 < n_areas {
map[link.area_1].push((li, 1));
}
if link.area_2 < n_areas {
map[link.area_2].push((li, 2));
}
}
map
}
pub fn x_update_area(
area: &AdmmArea,
y_boundary: &[f64],
z_boundary: &[f64],
rho: f64,
n_gen: usize,
n_bvars: usize,
) -> Result<Vec<f64>> {
if n_gen == 0 && n_bvars == 0 {
return Ok(Vec::new());
}
let p_bnd: Vec<f64> = (0..n_bvars)
.map(|j| {
let z_j = z_boundary.get(j).copied().unwrap_or(0.0);
let y_j = y_boundary.get(j).copied().unwrap_or(0.0);
z_j - y_j / rho.max(1e-15)
})
.collect();
let net_export: f64 = p_bnd.iter().sum();
let target_gen = area.total_load_mw() + net_export;
let p_gen = if n_gen == 0 {
Vec::new()
} else {
economic_dispatch_lambda(&area.generators, target_gen)?
};
let mut result = Vec::with_capacity(n_gen + n_bvars);
result.extend_from_slice(&p_gen);
result.extend_from_slice(&p_bnd);
Ok(result)
}
pub fn z_update(
x: &[Vec<f64>],
y: &[Vec<f64>],
links: &[BoundaryLink],
area_bmap: &[Vec<(usize, u8)>],
n_areas: usize,
n_boundary: usize,
rho: f64,
) -> Vec<f64> {
let mut z = vec![0.0_f64; n_boundary];
let n_gen_per_area: Vec<usize> = (0..n_areas)
.map(|a| {
let x_len = x.get(a).map(|v| v.len()).unwrap_or(0);
let b_len = area_bmap.get(a).map(|b| b.len()).unwrap_or(0);
x_len.saturating_sub(b_len)
})
.collect();
for li in 0..n_boundary {
let link = &links[li];
let a1 = link.area_1;
let a2 = link.area_2;
let extract = |area_idx: usize| -> Option<f64> {
if area_idx >= n_areas {
return None;
}
let bvars = area_bmap.get(area_idx)?;
let local_pos = bvars.iter().position(|(idx, _)| *idx == li)?;
let xi = n_gen_per_area.get(area_idx).copied().unwrap_or(0) + local_pos;
let x_val = x.get(area_idx)?.get(xi).copied()?;
let y_val = y.get(area_idx)?.get(local_pos).copied().unwrap_or(0.0);
Some(x_val + y_val / rho.max(1e-15))
};
let val_a1 = extract(a1);
let val_a2 = extract(a2);
z[li] = match (val_a1, val_a2) {
(Some(v1), Some(v2)) => (v1 + v2) / 2.0,
(Some(v), None) | (None, Some(v)) => v,
(None, None) => 0.0,
};
}
z
}
#[allow(clippy::ptr_arg)]
pub fn y_update(
y: &mut Vec<Vec<f64>>,
x: &[Vec<f64>],
z: &[f64],
_links: &[BoundaryLink],
area_bmap: &[Vec<(usize, u8)>],
rho: f64,
) {
let n_areas = y.len();
for a in 0..n_areas {
let n_gen = x
.get(a)
.map(|v| v.len())
.unwrap_or(0)
.saturating_sub(area_bmap.get(a).map(|b| b.len()).unwrap_or(0));
let bvars = match area_bmap.get(a) {
Some(b) => b,
None => continue,
};
for (j, (li, _side)) in bvars.iter().enumerate() {
let xi = n_gen + j;
let x_val = x.get(a).and_then(|v| v.get(xi)).copied().unwrap_or(0.0);
let z_val = z.get(*li).copied().unwrap_or(0.0);
if let Some(yv) = y.get_mut(a).and_then(|v| v.get_mut(j)) {
*yv += rho * (x_val - z_val);
}
}
}
}
pub fn compute_primal_residual(
x: &[Vec<f64>],
z: &[f64],
_links: &[BoundaryLink],
area_bmap: &[Vec<(usize, u8)>],
) -> f64 {
let mut sq_sum = 0.0_f64;
let n_areas = x.len();
for a in 0..n_areas {
let n_gen = x
.get(a)
.map(|v| v.len())
.unwrap_or(0)
.saturating_sub(area_bmap.get(a).map(|b| b.len()).unwrap_or(0));
let bvars = match area_bmap.get(a) {
Some(b) => b,
None => continue,
};
for (j, (li, _)) in bvars.iter().enumerate() {
let xi = n_gen + j;
let x_val = x.get(a).and_then(|v| v.get(xi)).copied().unwrap_or(0.0);
let z_val = z.get(*li).copied().unwrap_or(0.0);
let diff = x_val - z_val;
sq_sum += diff * diff;
}
}
sq_sum.sqrt()
}
pub fn compute_dual_residual(z_old: &[f64], z_new: &[f64], rho: f64) -> f64 {
let sq_sum: f64 = z_old
.iter()
.zip(z_new.iter())
.map(|(&zo, &zn)| {
let d = rho * (zn - zo);
d * d
})
.sum();
sq_sum.sqrt()
}
#[allow(clippy::ptr_arg)]
pub fn update_rho(
rho: &mut f64,
primal_res: f64,
dual_res: f64,
scale: f64,
y: &mut Vec<Vec<f64>>,
) {
let threshold = 10.0_f64;
let old_rho = *rho;
if primal_res > threshold * dual_res {
*rho *= scale;
} else if dual_res > threshold * primal_res {
*rho /= scale.max(1e-15);
}
if ((*rho) - old_rho).abs() > 1e-15 {
let factor = old_rho / (*rho).max(1e-15);
for ya in y.iter_mut() {
for yv in ya.iter_mut() {
*yv *= factor;
}
}
}
}
pub fn compute_total_cost(areas: &[AdmmArea], dispatches: &[Vec<f64>]) -> f64 {
let mut cost = 0.0_f64;
for (a, area) in areas.iter().enumerate() {
if let Some(p_gen) = dispatches.get(a) {
for (gi, gen) in area.generators.iter().enumerate() {
if let Some(&p) = p_gen.get(gi) {
cost += gen.total_cost(p);
}
}
}
}
cost
}
pub fn build_result(
&self,
state: &AdmmState,
area_bmap: &[Vec<(usize, u8)>],
converged: bool,
iterations: usize,
primal_history: Vec<f64>,
dual_history: Vec<f64>,
) -> Result<AdmmResult> {
let n_areas = self.areas.len();
let gen_dispatches: Vec<Vec<f64>> = (0..n_areas)
.map(|a| {
let n_gen = self.areas[a].generators.len();
state
.x
.get(a)
.map(|xv| xv[..n_gen.min(xv.len())].to_vec())
.unwrap_or_default()
})
.collect();
let total_cost = Self::compute_total_cost(&self.areas, &gen_dispatches);
let area_dispatches: Vec<AdmmAreaDispatch> = (0..n_areas)
.map(|a| {
let area = &self.areas[a];
let n_gen = area.generators.len();
let p_gen: Vec<f64> = state
.x
.get(a)
.map(|xv| xv[..n_gen.min(xv.len())].to_vec())
.unwrap_or_default();
let area_cost: f64 = area
.generators
.iter()
.zip(p_gen.iter())
.map(|(g, &p)| g.total_cost(p))
.sum();
let q_gen: Vec<f64> = area
.generators
.iter()
.zip(p_gen.iter())
.map(|(g, &p)| {
let p_range = g.p_max_mw - g.p_min_mw;
if p_range.abs() < 1e-10 {
0.5 * (g.q_min_mvar + g.q_max_mvar)
} else {
let frac = ((p - g.p_min_mw) / p_range).clamp(0.0, 1.0);
g.q_min_mvar + frac * (g.q_max_mvar - g.q_min_mvar)
}
})
.collect();
let bus_voltages = vec![1.0_f64; area.bus_indices.len()];
let import_export: Vec<f64> = area_bmap
.get(a)
.map(|bvars| {
bvars
.iter()
.enumerate()
.map(|(j, _)| {
let xi = n_gen + j;
state
.x
.get(a)
.and_then(|xv| xv.get(xi))
.copied()
.unwrap_or(0.0)
})
.collect()
})
.unwrap_or_default();
AdmmAreaDispatch {
area_id: area.id,
generator_dispatch_mw: p_gen,
generator_dispatch_mvar: q_gen,
bus_voltages,
area_cost_per_h: area_cost,
import_export_mw: import_export,
}
})
.collect();
let boundary_voltages = vec![1.0_f64; self.boundary_links.len()];
let convergence_rate = if primal_history.len() >= 2 {
let first = primal_history.first().copied().unwrap_or(1.0);
let last = primal_history.last().copied().unwrap_or(1.0);
let n = primal_history.len() as f64;
if first > 1e-15 && last > 0.0 {
(last / first).powf(1.0 / n)
} else {
0.0
}
} else {
0.0
};
Ok(AdmmResult {
converged,
iterations,
area_dispatches,
boundary_voltages,
total_cost_per_h: total_cost,
primal_residual_history: primal_history,
dual_residual_history: dual_history,
convergence_rate,
})
}
}
fn economic_dispatch_lambda(generators: &[AdmmGenerator], target_mw: f64) -> Result<Vec<f64>> {
if generators.is_empty() {
return Ok(Vec::new());
}
let p_min_total: f64 = generators.iter().map(|g| g.p_min_mw).sum();
let p_max_total: f64 = generators.iter().map(|g| g.p_max_mw).sum();
let target_clamped = target_mw.clamp(p_min_total, p_max_total);
let all_linear = generators.iter().all(|g| g.cost_a.abs() < 1e-15);
if all_linear {
return dispatch_linear(generators, target_clamped);
}
economic_dispatch_bisect(generators, target_clamped)
}
fn economic_dispatch_bisect(generators: &[AdmmGenerator], target_mw: f64) -> Result<Vec<f64>> {
let mut lo = generators
.iter()
.map(|g| g.marginal_cost(g.p_min_mw))
.fold(f64::INFINITY, f64::min)
- 1.0;
let mut hi = generators
.iter()
.map(|g| g.marginal_cost(g.p_max_mw))
.fold(f64::NEG_INFINITY, f64::max)
+ 1.0;
let dispatch_at = |lam: f64| -> Vec<f64> {
generators
.iter()
.map(|g| {
if g.cost_a.abs() < 1e-15 {
if lam >= g.cost_b {
g.p_max_mw
} else {
g.p_min_mw
}
} else {
let p_star = (lam - g.cost_b) / (2.0 * g.cost_a);
p_star.clamp(g.p_min_mw, g.p_max_mw)
}
})
.collect()
};
let total_at = |lam: f64| -> f64 { dispatch_at(lam).iter().sum() };
for _ in 0..30 {
if total_at(lo) > target_mw {
lo -= (hi - lo).abs().max(1.0);
} else {
break;
}
}
for _ in 0..30 {
if total_at(hi) < target_mw {
hi += (hi - lo).abs().max(1.0);
} else {
break;
}
}
for _ in 0..64 {
let mid = 0.5 * (lo + hi);
if total_at(mid) < target_mw {
lo = mid;
} else {
hi = mid;
}
if (hi - lo).abs() < 1e-10 {
break;
}
}
Ok(dispatch_at(0.5 * (lo + hi)))
}
fn dispatch_linear(generators: &[AdmmGenerator], target_mw: f64) -> Result<Vec<f64>> {
let p_min_total: f64 = generators.iter().map(|g| g.p_min_mw).sum();
let headroom: f64 = generators.iter().map(|g| g.p_max_mw - g.p_min_mw).sum();
let extra = (target_mw - p_min_total).clamp(0.0, headroom);
let mut result: Vec<f64> = generators.iter().map(|g| g.p_min_mw).collect();
if headroom > 1e-10 {
for (i, g) in generators.iter().enumerate() {
let cap = g.p_max_mw - g.p_min_mw;
result[i] += extra * cap / headroom;
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_gen(bus: usize, p_min: f64, p_max: f64, cost_a: f64, cost_b: f64) -> AdmmGenerator {
AdmmGenerator {
bus,
p_min_mw: p_min,
p_max_mw: p_max,
q_min_mvar: -50.0,
q_max_mvar: 50.0,
cost_a,
cost_b,
}
}
fn make_area(
id: usize,
bus_indices: Vec<usize>,
boundary_buses: Vec<usize>,
generators: Vec<AdmmGenerator>,
loads_mw: Vec<f64>,
) -> AdmmArea {
let loads_mvar = vec![0.0; loads_mw.len()];
AdmmArea {
id,
bus_indices,
boundary_buses,
generators,
loads_mw,
loads_mvar,
}
}
fn two_area_setup() -> (Vec<AdmmArea>, Vec<BoundaryLink>) {
let area0 = make_area(
0,
vec![0, 1],
vec![1],
vec![make_gen(0, 10.0, 200.0, 0.005, 18.0)],
vec![60.0, 0.0],
);
let area1 = make_area(
1,
vec![2, 3],
vec![2],
vec![make_gen(2, 5.0, 150.0, 0.008, 22.0)],
vec![0.0, 50.0],
);
let links = vec![BoundaryLink {
bus_global: 5,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
(vec![area0, area1], links)
}
#[test]
fn test_admm_area_creation() {
let area = make_area(
0,
vec![0, 1, 2],
vec![2],
vec![make_gen(0, 10.0, 100.0, 0.01, 20.0)],
vec![30.0, 20.0, 10.0],
);
assert_eq!(area.id, 0);
assert_eq!(area.bus_indices.len(), 3);
assert_eq!(area.boundary_buses.len(), 1);
assert_eq!(area.generators.len(), 1);
assert!((area.total_load_mw() - 60.0).abs() < 1e-9);
}
#[test]
fn test_boundary_link_creation() {
let link = BoundaryLink {
bus_global: 5,
area_1: 0,
bus_in_area_1: 2,
area_2: 1,
bus_in_area_2: 0,
};
assert_eq!(link.bus_global, 5);
assert_eq!(link.area_1, 0);
assert_eq!(link.area_2, 1);
assert_eq!(link.bus_in_area_1, 2);
assert_eq!(link.bus_in_area_2, 0);
}
#[test]
fn test_admm_config_default() {
let cfg = AdmmConfig::default();
assert!((cfg.rho - 1.0).abs() < 1e-9);
assert_eq!(cfg.max_iterations, 200);
assert!((cfg.primal_tol - 1e-4).abs() < 1e-12);
assert!((cfg.dual_tol - 1e-4).abs() < 1e-12);
assert!(cfg.rho_update_enabled);
assert!((cfg.rho_scale_factor - 2.0).abs() < 1e-9);
assert!((cfg.base_mva - 100.0).abs() < 1e-9);
}
#[test]
fn test_x_update_single_area_no_coupling() {
let area = make_area(
0,
vec![0],
vec![],
vec![make_gen(0, 0.0, 200.0, 0.01, 20.0)],
vec![80.0],
);
let result = AdmmOpf::x_update_area(&area, &[], &[], 1.0, 1, 0).expect("x_update failed");
assert_eq!(result.len(), 1);
assert!(
(result[0] - 80.0).abs() < 1.0,
"expected ~80 MW, got {}",
result[0]
);
}
#[test]
fn test_x_update_with_coupling_term() {
let area = make_area(
0,
vec![0, 1],
vec![1],
vec![make_gen(0, 0.0, 200.0, 0.01, 20.0)],
vec![50.0, 0.0],
);
let result =
AdmmOpf::x_update_area(&area, &[0.0], &[10.0], 1.0, 1, 1).expect("x_update failed");
assert_eq!(result.len(), 2);
assert!(
(result[0] - 60.0).abs() < 2.0,
"expected ~60 MW gen, got {}",
result[0]
);
assert!(
(result[1] - 10.0).abs() < 1e-6,
"expected p_bnd=10, got {}",
result[1]
);
}
#[test]
fn test_z_update_consensus_equal() {
let links = vec![BoundaryLink {
bus_global: 2,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
let area_bmap = AdmmOpf::build_area_boundary_map(2, &links);
let x = vec![vec![50.0_f64, 5.0], vec![30.0_f64, 5.0]];
let y = vec![vec![0.0_f64], vec![0.0_f64]];
let z = AdmmOpf::z_update(&x, &y, &links, &area_bmap, 2, 1, 1.0);
assert_eq!(z.len(), 1);
assert!(
(z[0] - 5.0).abs() < 1e-9,
"consensus should be 5.0, got {}",
z[0]
);
}
#[test]
fn test_z_update_consensus_average() {
let links = vec![BoundaryLink {
bus_global: 2,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
let area_bmap = AdmmOpf::build_area_boundary_map(2, &links);
let x = vec![vec![50.0_f64, 8.0], vec![30.0_f64, 4.0]];
let y = vec![vec![0.0_f64], vec![0.0_f64]];
let z = AdmmOpf::z_update(&x, &y, &links, &area_bmap, 2, 1, 1.0);
assert!(
(z[0] - 6.0).abs() < 1e-9,
"consensus average should be 6.0, got {}",
z[0]
);
}
#[test]
fn test_y_update_dual_ascent() {
let links = vec![BoundaryLink {
bus_global: 2,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
let area_bmap = AdmmOpf::build_area_boundary_map(2, &links);
let x = vec![vec![50.0_f64, 8.0], vec![30.0_f64, 4.0]];
let z = vec![6.0_f64];
let mut y = vec![vec![0.0_f64], vec![0.0_f64]];
AdmmOpf::y_update(&mut y, &x, &z, &links, &area_bmap, 1.0);
assert!(
(y[0][0] - 2.0).abs() < 1e-9,
"y[0][0] should be 2.0, got {}",
y[0][0]
);
assert!(
(y[1][0] + 2.0).abs() < 1e-9,
"y[1][0] should be -2.0, got {}",
y[1][0]
);
}
#[test]
fn test_primal_residual_zero() {
let links = vec![BoundaryLink {
bus_global: 2,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
let area_bmap = AdmmOpf::build_area_boundary_map(2, &links);
let x = vec![vec![50.0_f64, 5.0], vec![30.0_f64, 5.0]];
let z = vec![5.0_f64];
let res = AdmmOpf::compute_primal_residual(&x, &z, &links, &area_bmap);
assert!(
res.abs() < 1e-9,
"primal residual should be 0 when x == z, got {}",
res
);
}
#[test]
fn test_primal_residual_nonzero() {
let links = vec![BoundaryLink {
bus_global: 2,
area_1: 0,
bus_in_area_1: 1,
area_2: 1,
bus_in_area_2: 0,
}];
let area_bmap = AdmmOpf::build_area_boundary_map(2, &links);
let x = vec![vec![50.0_f64, 8.0], vec![30.0_f64, 4.0]];
let z = vec![6.0_f64];
let res = AdmmOpf::compute_primal_residual(&x, &z, &links, &area_bmap);
assert!(
(res - 8.0_f64.sqrt()).abs() < 1e-9,
"expected sqrt(8), got {}",
res
);
}
#[test]
fn test_dual_residual_zero() {
let z = vec![1.0_f64, 2.0, 3.0];
let res = AdmmOpf::compute_dual_residual(&z, &z, 1.0);
assert!(
res.abs() < 1e-12,
"dual residual should be 0 when z unchanged, got {}",
res
);
}
#[test]
fn test_dual_residual_nonzero() {
let z_old = vec![0.0_f64];
let z_new = vec![3.0_f64];
let res = AdmmOpf::compute_dual_residual(&z_old, &z_new, 2.0);
assert!(
(res - 6.0).abs() < 1e-9,
"expected dual residual = 6.0, got {}",
res
);
}
#[test]
fn test_rho_update_primal_larger() {
let mut rho = 1.0_f64;
let mut y = vec![vec![2.0_f64]];
AdmmOpf::update_rho(&mut rho, 100.0, 1.0, 2.0, &mut y);
assert!(
rho > 1.0,
"rho should increase when primal residual dominates, got {}",
rho
);
assert!(
y[0][0] < 2.0,
"dual variable should decrease after rho increase, got {}",
y[0][0]
);
}
#[test]
fn test_rho_update_dual_larger() {
let mut rho = 2.0_f64;
let mut y = vec![vec![1.0_f64]];
AdmmOpf::update_rho(&mut rho, 0.1, 100.0, 2.0, &mut y);
assert!(
rho < 2.0,
"rho should decrease when dual residual dominates, got {}",
rho
);
assert!(
y[0][0] > 1.0,
"dual variable should increase after rho decrease, got {}",
y[0][0]
);
}
#[test]
fn test_solve_single_area() {
let area = make_area(
0,
vec![0, 1],
vec![],
vec![
make_gen(0, 10.0, 150.0, 0.01, 20.0),
make_gen(1, 5.0, 100.0, 0.02, 15.0),
],
vec![80.0, 40.0],
);
let mut solver = AdmmOpf::new(vec![area], vec![], AdmmConfig::default());
let result = solver.solve().expect("solve failed");
assert_eq!(result.area_dispatches.len(), 1);
let dispatch = &result.area_dispatches[0];
let total_gen: f64 = dispatch.generator_dispatch_mw.iter().sum();
assert!(
(total_gen - 120.0).abs() < 5.0,
"total dispatch should be ~120 MW, got {}",
total_gen
);
assert!(result.total_cost_per_h > 0.0);
assert!(
result.converged,
"single-area with no boundary should converge immediately"
);
}
#[test]
fn test_solve_two_areas_converges() {
let (areas, links) = two_area_setup();
let cfg = AdmmConfig {
max_iterations: 300,
primal_tol: 1e-3,
dual_tol: 1e-3,
..AdmmConfig::default()
};
let mut solver = AdmmOpf::new(areas, links, cfg);
let result = solver.solve().expect("solve failed");
assert_eq!(result.area_dispatches.len(), 2);
assert!(
result.converged || result.iterations == 300,
"solver should converge or exhaust iterations"
);
assert!(result.total_cost_per_h >= 0.0);
assert_eq!(result.boundary_voltages.len(), 1);
}
#[test]
fn test_solve_convergence_tolerance() {
let area = make_area(
0,
vec![0],
vec![],
vec![make_gen(0, 0.0, 100.0, 0.01, 10.0)],
vec![50.0],
);
let mut solver = AdmmOpf::new(vec![area], vec![], AdmmConfig::default());
let result = solver.solve().expect("solve failed");
assert!(
result.converged,
"single area with no boundary should converge"
);
assert!(result.iterations <= 200);
}
#[test]
fn test_solve_residual_history_length() {
let (areas, links) = two_area_setup();
let cfg = AdmmConfig {
max_iterations: 50,
..AdmmConfig::default()
};
let mut solver = AdmmOpf::new(areas, links, cfg);
let result = solver.solve().expect("solve failed");
assert_eq!(
result.primal_residual_history.len(),
result.dual_residual_history.len(),
"primal and dual history lengths must match"
);
assert_eq!(
result.primal_residual_history.len(),
result.iterations,
"history length must equal iteration count"
);
}
#[test]
fn test_total_cost_computation() {
let gen = make_gen(0, 0.0, 200.0, 0.01, 20.0);
let area = AdmmArea {
id: 0,
bus_indices: vec![0],
boundary_buses: vec![],
generators: vec![gen],
loads_mw: vec![100.0],
loads_mvar: vec![0.0],
};
let dispatches = vec![vec![100.0_f64]];
let cost = AdmmOpf::compute_total_cost(&[area], &dispatches);
assert!(
(cost - 2100.0).abs() < 1e-6,
"expected cost 2100, got {}",
cost
);
}
#[test]
fn test_admm_result_structure() {
let area = make_area(
0,
vec![0, 1],
vec![],
vec![make_gen(0, 10.0, 100.0, 0.01, 20.0)],
vec![50.0, 30.0],
);
let mut solver = AdmmOpf::new(vec![area], vec![], AdmmConfig::default());
let result = solver.solve().expect("solve failed");
assert_eq!(result.area_dispatches.len(), 1);
let d = &result.area_dispatches[0];
assert_eq!(d.area_id, 0);
assert_eq!(d.generator_dispatch_mw.len(), 1);
assert_eq!(d.generator_dispatch_mvar.len(), 1);
assert_eq!(d.bus_voltages.len(), 2); assert_eq!(result.boundary_voltages.len(), 0); assert!(result.convergence_rate >= 0.0);
assert!(result.total_cost_per_h >= 0.0);
assert!(result.primal_residual_history.len() == result.iterations);
assert!(result.dual_residual_history.len() == result.iterations);
}
}