use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LrOpfError {
#[error("infeasible: total P_max ({total_pmax:.2} MW) < total load ({load:.2} MW)")]
Infeasible { total_pmax: f64, load: f64 },
#[error("no generators defined")]
NoGenerators,
#[error("load vector length {got} does not match n_buses {expected}")]
DimensionMismatch { got: usize, expected: usize },
#[error("numerical error: {0}")]
NumericalError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LrOpfConfig {
pub n_buses: usize,
pub n_generators: usize,
pub max_iterations: usize,
pub step_size_init: f64,
pub step_size_decay: f64,
pub convergence_tolerance: f64,
pub lower_bound_update: usize,
}
impl Default for LrOpfConfig {
fn default() -> Self {
Self {
n_buses: 1,
n_generators: 1,
max_iterations: 500,
step_size_init: 1.0,
step_size_decay: 0.999,
convergence_tolerance: 0.01, lower_bound_update: 10,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratorForLr {
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,
pub cost_c: f64,
}
impl GeneratorForLr {
pub fn cost(&self, p_mw: f64) -> f64 {
self.cost_c + self.cost_b * p_mw + self.cost_a * p_mw * p_mw
}
pub fn marginal_cost(&self, p_mw: f64) -> f64 {
self.cost_b + 2.0 * self.cost_a * p_mw
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchForLr {
pub from_bus: usize,
pub to_bus: usize,
pub susceptance_pu: f64,
pub rating_mw: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LrOpfResult {
pub generation_mw: Vec<f64>,
pub angles_rad: Vec<f64>,
pub dual_variables: Vec<f64>,
pub lower_bound: f64,
pub upper_bound: f64,
pub duality_gap_pct: f64,
pub n_iterations: usize,
pub converged: bool,
pub lmp: Vec<f64>,
}
pub struct LrOpfSolver {
config: LrOpfConfig,
generators: Vec<GeneratorForLr>,
branches: Vec<BranchForLr>,
load_mw: Vec<f64>,
#[allow(dead_code)]
slack_bus: usize,
}
impl LrOpfSolver {
pub fn new(config: LrOpfConfig, slack_bus: usize) -> Self {
Self {
config,
generators: Vec::new(),
branches: Vec::new(),
load_mw: Vec::new(),
slack_bus,
}
}
pub fn add_generator(&mut self, gen: GeneratorForLr) {
self.generators.push(gen);
}
pub fn add_branch(&mut self, branch: BranchForLr) {
self.branches.push(branch);
}
pub fn set_loads(&mut self, load_mw: Vec<f64>) {
self.load_mw = load_mw;
}
fn generator_optimal_dispatch(&self, gen: &GeneratorForLr, lambda: f64) -> f64 {
let p_star = if gen.cost_a.abs() > 1e-12 {
(lambda - gen.cost_b) / (2.0 * gen.cost_a)
} else {
if lambda > gen.cost_b {
gen.p_max_mw
} else {
gen.p_min_mw
}
};
p_star.clamp(gen.p_min_mw, gen.p_max_mw)
}
fn lagrangian_value(&self, dispatches: &[f64], lambda: f64) -> f64 {
let total_load: f64 = self.load_mw.iter().sum();
let gen_contribution: f64 = dispatches
.iter()
.zip(self.generators.iter())
.map(|(&p, gen)| gen.cost(p) - lambda * p)
.sum();
gen_contribution + lambda * total_load
}
fn feasible_dispatch(&self, _dispatches: &[f64]) -> (Vec<f64>, f64) {
let total_load: f64 = self.load_mw.iter().sum();
let n = self.generators.len();
if n == 0 {
return (Vec::new(), 0.0);
}
let total_pmin: f64 = self.generators.iter().map(|g| g.p_min_mw).sum();
let total_pmax: f64 = self.generators.iter().map(|g| g.p_max_mw).sum();
let load_clamped = total_load.clamp(total_pmin, total_pmax);
let lambda_lo = self
.generators
.iter()
.map(|g| g.marginal_cost(g.p_min_mw))
.fold(f64::NEG_INFINITY, f64::max);
let lambda_hi = self
.generators
.iter()
.map(|g| g.marginal_cost(g.p_max_mw))
.fold(f64::INFINITY, f64::min)
.max(lambda_lo + 100.0);
let mut lo = lambda_lo;
let mut hi = lambda_hi;
for _ in 0..50 {
let mid = 0.5 * (lo + hi);
let gen_mid: f64 = self
.generators
.iter()
.map(|g| self.generator_optimal_dispatch(g, mid))
.sum();
if gen_mid < load_clamped {
lo = mid;
} else {
hi = mid;
}
if (hi - lo).abs() < 1e-8 {
break;
}
}
let lambda_opt = 0.5 * (lo + hi);
let feasible: Vec<f64> = self
.generators
.iter()
.map(|g| self.generator_optimal_dispatch(g, lambda_opt))
.collect();
let cost = feasible
.iter()
.zip(self.generators.iter())
.map(|(&p, g)| g.cost(p))
.sum();
(feasible, cost)
}
pub fn solve(&self) -> Result<LrOpfResult, LrOpfError> {
if self.generators.is_empty() {
return Err(LrOpfError::NoGenerators);
}
let n_buses = self.config.n_buses;
if !self.load_mw.is_empty() && self.load_mw.len() != n_buses {
return Err(LrOpfError::DimensionMismatch {
got: self.load_mw.len(),
expected: n_buses,
});
}
let total_load: f64 = self.load_mw.iter().sum();
let total_pmax: f64 = self.generators.iter().map(|g| g.p_max_mw).sum();
if total_pmax < total_load - 1e-6 {
return Err(LrOpfError::Infeasible {
total_pmax,
load: total_load,
});
}
let mut lambda = 0.0_f64;
let mut step = self.config.step_size_init;
let mut best_lower_bound = f64::NEG_INFINITY;
let mut best_upper_bound = f64::INFINITY;
let mut best_generation: Vec<f64> = vec![0.0; self.generators.len()];
let mut converged = false;
let mut n_iterations = 0usize;
for iter in 0..self.config.max_iterations {
n_iterations = iter + 1;
let dispatches: Vec<f64> = self
.generators
.iter()
.map(|g| self.generator_optimal_dispatch(g, lambda))
.collect();
let total_gen: f64 = dispatches.iter().sum();
let subgradient = total_load - total_gen;
let lb = self.lagrangian_value(&dispatches, lambda);
if lb > best_lower_bound {
best_lower_bound = lb;
}
{
let (feas_dispatch, ub) = self.feasible_dispatch(&dispatches);
if ub < best_upper_bound {
best_upper_bound = ub;
best_generation = feas_dispatch;
}
}
if subgradient.abs() < 1e-6 {
converged = true;
break;
}
if best_upper_bound < f64::INFINITY && best_lower_bound > f64::NEG_INFINITY {
let ref_val = best_upper_bound.abs().max(1.0);
let gap_pct = (best_upper_bound - best_lower_bound).abs() / ref_val * 100.0;
if gap_pct < self.config.convergence_tolerance {
converged = true;
break;
}
}
lambda += step * subgradient;
step *= self.config.step_size_decay;
}
if best_generation.is_empty() || best_upper_bound == f64::INFINITY {
let last_dispatches: Vec<f64> = self
.generators
.iter()
.map(|g| self.generator_optimal_dispatch(g, lambda))
.collect();
let (fd, ub) = self.feasible_dispatch(&last_dispatches);
best_generation = fd;
best_upper_bound = ub;
}
let gap_pct = if best_upper_bound.abs() > 1e-9 {
(best_upper_bound - best_lower_bound).abs() / best_upper_bound.abs() * 100.0
} else {
0.0
};
let lmp = vec![lambda; n_buses];
let angles_rad = vec![0.0_f64; n_buses];
Ok(LrOpfResult {
generation_mw: best_generation,
angles_rad,
dual_variables: lmp.clone(),
lower_bound: best_lower_bound,
upper_bound: best_upper_bound,
duality_gap_pct: gap_pct,
n_iterations,
converged,
lmp,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_gen(p_min: f64, p_max: f64, a: f64, b: f64, c: f64) -> GeneratorForLr {
GeneratorForLr {
bus: 0,
p_min_mw: p_min,
p_max_mw: p_max,
q_min_mvar: -50.0,
q_max_mvar: 50.0,
cost_a: a,
cost_b: b,
cost_c: c,
}
}
#[test]
fn test_single_generator_analytical() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 1,
max_iterations: 1000,
step_size_init: 0.5,
step_size_decay: 0.998,
convergence_tolerance: 0.05,
lower_bound_update: 5,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(0.0, 200.0, 0.01, 20.0, 100.0));
solver.set_loads(vec![80.0]);
let result = solver.solve().expect("must solve");
assert!(result.converged, "must converge for single generator");
let p = result.generation_mw[0];
assert!(
(p - 80.0).abs() < 2.0,
"dispatch must be close to 80 MW, got {:.2}",
p
);
let expected_lmp = 20.0 + 2.0 * 0.01 * 80.0;
assert!(
(result.lmp[0] - expected_lmp).abs() < 2.0,
"LMP must be near {:.2}, got {:.4}",
expected_lmp,
result.lmp[0]
);
}
#[test]
fn test_two_generators_constrained_optimum() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 2,
max_iterations: 2000,
step_size_init: 1.0,
step_size_decay: 0.997,
convergence_tolerance: 0.1,
lower_bound_update: 10,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(0.0, 100.0, 0.01, 15.0, 50.0));
solver.add_generator(GeneratorForLr {
bus: 0,
p_min_mw: 0.0,
p_max_mw: 100.0,
q_min_mvar: -50.0,
q_max_mvar: 50.0,
cost_a: 0.02,
cost_b: 20.0,
cost_c: 80.0,
});
solver.set_loads(vec![100.0]);
let result = solver.solve().expect("must solve");
let total_gen: f64 = result.generation_mw.iter().sum();
assert!(
(total_gen - 100.0).abs() < 5.0,
"total generation must be ~100 MW, got {:.2}",
total_gen
);
assert!(
(result.generation_mw[0] - 100.0).abs() < 2.0,
"P1 must be ~100 MW at P_max, got {:.2}",
result.generation_mw[0]
);
assert!(
result.upper_bound < 3000.0,
"total cost must be reasonable (<3000), got {:.2}",
result.upper_bound
);
}
#[test]
fn test_two_generators_equal_incremental_cost() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 2,
max_iterations: 2000,
step_size_init: 1.0,
step_size_decay: 0.997,
convergence_tolerance: 0.1,
lower_bound_update: 10,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(GeneratorForLr {
bus: 0,
p_min_mw: 0.0,
p_max_mw: 200.0,
q_min_mvar: -50.0,
q_max_mvar: 50.0,
cost_a: 0.01,
cost_b: 15.0,
cost_c: 50.0,
});
solver.add_generator(GeneratorForLr {
bus: 0,
p_min_mw: 0.0,
p_max_mw: 200.0,
q_min_mvar: -50.0,
q_max_mvar: 50.0,
cost_a: 0.02,
cost_b: 16.0,
cost_c: 80.0,
});
solver.set_loads(vec![100.0]);
let result = solver.solve().expect("must solve");
let total_gen: f64 = result.generation_mw.iter().sum();
assert!(
(total_gen - 100.0).abs() < 5.0,
"total generation must be ~100 MW, got {:.2}",
total_gen
);
assert!(result.generation_mw[0] > 1.0, "Gen1 must produce power");
assert!(result.generation_mw[1] > 1.0, "Gen2 must produce power");
let mc1 = 15.0 + 2.0 * 0.01 * result.generation_mw[0];
let mc2 = 16.0 + 2.0 * 0.02 * result.generation_mw[1];
assert!(
(mc1 - mc2).abs() < 2.0,
"incremental costs must equalise at unconstrained optimum: MC1={:.2} MC2={:.2}",
mc1,
mc2
);
}
#[test]
fn test_lmp_interpretation() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 1,
max_iterations: 1000,
step_size_init: 0.5,
step_size_decay: 0.999,
convergence_tolerance: 0.05,
lower_bound_update: 5,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(10.0, 150.0, 0.005, 25.0, 200.0));
solver.set_loads(vec![60.0]);
let result = solver.solve().expect("solve OK");
assert_eq!(result.lmp.len(), 1, "one LMP per bus");
assert!(result.lmp[0] > 0.0, "LMP must be positive");
}
#[test]
fn test_convergence_duality_gap() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 1,
max_iterations: 2000,
step_size_init: 0.5,
step_size_decay: 0.998,
convergence_tolerance: 0.1,
lower_bound_update: 5,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(0.0, 200.0, 0.01, 18.0, 50.0));
solver.set_loads(vec![100.0]);
let result = solver.solve().expect("solve OK");
assert!(result.converged, "must converge");
assert!(
result.duality_gap_pct < 1.0,
"duality gap must be < 1%, got {:.4}%",
result.duality_gap_pct
);
}
#[test]
fn test_lower_bound_le_upper_bound() {
let configs: Vec<(f64, f64, f64)> =
vec![(0.01, 20.0, 0.0), (0.005, 15.0, 0.0), (0.02, 25.0, 0.0)];
for (a, b, _c) in configs {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 1,
max_iterations: 300,
step_size_init: 1.0,
step_size_decay: 0.999,
convergence_tolerance: 0.01,
lower_bound_update: 5,
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(0.0, 100.0, a, b, 0.0));
solver.set_loads(vec![50.0]);
let result = solver.solve().expect("solve OK");
assert!(
result.lower_bound <= result.upper_bound + 1e-6,
"lower bound ({:.4}) must not exceed upper bound ({:.4})",
result.lower_bound,
result.upper_bound
);
}
}
#[test]
fn test_generator_dispatch_clamped_to_pmin() {
let config = LrOpfConfig::default();
let solver = LrOpfSolver::new(config, 0);
let gen = make_gen(20.0, 100.0, 0.01, 50.0, 0.0);
let p = solver.generator_optimal_dispatch(&gen, 0.0);
assert_eq!(p, 20.0, "dispatch must be clamped to P_min=20");
}
#[test]
fn test_infeasible_detected() {
let config = LrOpfConfig {
n_buses: 1,
n_generators: 1,
..LrOpfConfig::default()
};
let mut solver = LrOpfSolver::new(config, 0);
solver.add_generator(make_gen(0.0, 50.0, 0.01, 20.0, 0.0));
solver.set_loads(vec![200.0]); let result = solver.solve();
assert!(
matches!(result, Err(LrOpfError::Infeasible { .. })),
"must detect infeasibility"
);
}
}