use std::collections::HashMap;
use gridoxide::linear::DcApproximation;
use gridoxide::linear::btheta::DcBranch;
use gridoxide::opf::dc::{DcGenerator, DcLoad, DcOpf, DcOpfNetwork, DcOpfOptions};
use gridoxide::opf::ipm::IpmSolver;
use gridoxide::opf::model::{CostCurve, OpfData};
use gridoxide::opf::{OptStatus, Solver};
use gridoxide::pgm::PgmInput;
mod pglib;
const PUBLISHED_DC: &[(&str, f64)] = &[
("pglib_opf_case3_lmbd", 5.6959e+03),
("pglib_opf_case5_pjm", 1.7480e+04),
("pglib_opf_case14_ieee", 2.0515e+03),
("pglib_opf_case30_ieee", 7.4728e+03),
("pglib_opf_case118_ieee", 9.3101e+04),
];
fn solver() -> IpmSolver {
IpmSolver::new()
}
fn load_documents(name: &str) -> (PgmInput, OpfData) {
let network_text = std::fs::read_to_string(pglib::path(name, ".json")).unwrap();
let opf_text = std::fs::read_to_string(pglib::path(name, ".opf.json")).unwrap();
(
serde_json::from_str(&network_text).unwrap(),
OpfData::from_json(&opf_text).unwrap(),
)
}
fn load_case(name: &str) -> DcOpfNetwork {
let (input, data) = load_documents(name);
gridoxide::pgm::opf::dc_opf_network_from_pgm(input, &data, 50.0, DcApproximation::IgnoreG)
.unwrap()
}
#[track_caller]
fn assert_kkt(opf: &DcOpf, solution: &gridoxide::opf::Solution, tol: f64) {
let lp = opf.problem();
let mut lhs = lp.col_cost.clone();
if let Some(hessian) = &lp.hessian {
for &(i, j, v) in hessian {
lhs[i] += v * solution.primal[j];
if i != j {
lhs[j] += v * solution.primal[i];
}
}
}
for &(row, col, v) in &lp.rows {
lhs[col] -= v * solution.row_dual[row];
}
for k in 0..lp.n_vars {
assert!(
(solution.col_dual[k] - lhs[k]).abs() < tol,
"stationarity at column {k}: {} vs {}",
solution.col_dual[k],
lhs[k]
);
}
for k in 0..lp.n_vars {
let x = solution.primal[k];
assert!(
x >= lp.col_lower[k] - tol && x <= lp.col_upper[k] + tol,
"column {k} = {x} outside [{}, {}]",
lp.col_lower[k],
lp.col_upper[k]
);
}
for k in 0..lp.n_rows {
let a = solution.row_activity[k];
assert!(
a >= lp.row_lower[k] - tol && a <= lp.row_upper[k] + tol,
"row {k} = {a} outside [{}, {}]",
lp.row_lower[k],
lp.row_upper[k]
);
}
for k in 0..lp.n_rows {
let a = solution.row_activity[k];
let slack_below = a - lp.row_lower[k];
let slack_above = lp.row_upper[k] - a;
if slack_below > 1e-6 && slack_above > 1e-6 {
assert!(
solution.row_dual[k].abs() < 1e-6,
"row {k} is slack ({a} in [{}, {}]) but prices at {}",
lp.row_lower[k],
lp.row_upper[k],
solution.row_dual[k]
);
}
}
}
#[test]
fn an_uncongested_two_bus_case_dispatches_the_cheap_unit_only() {
let network = two_bus(None);
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert_eq!(solution.status, OptStatus::Optimal);
assert_kkt(&opf, &solution, 1e-7);
let result = opf.interpret(&solution);
assert!(
(result.dispatch[0] - 100.0).abs() < 1e-6,
"{:?}",
result.dispatch
);
assert!(result.dispatch[1].abs() < 1e-6, "{:?}", result.dispatch);
assert!(
(result.objective - 1000.0).abs() < 1e-6,
"{}",
result.objective
);
for (bus, lmp) in result.lmp.iter().enumerate() {
assert!(
(lmp - 10.0).abs() < 1e-6,
"bus {bus} prices at {lmp}, expected 10"
);
}
assert!(result.binding.is_empty());
assert!(result.shed.iter().all(|s| s.abs() < 1e-6));
}
#[test]
fn a_congested_two_bus_case_splits_the_price() {
let network = two_bus(Some(0.4));
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert_eq!(solution.status, OptStatus::Optimal);
assert_kkt(&opf, &solution, 1e-7);
let result = opf.interpret(&solution);
assert!(
(result.dispatch[0] - 40.0).abs() < 1e-6,
"{:?}",
result.dispatch
);
assert!(
(result.dispatch[1] - 60.0).abs() < 1e-6,
"{:?}",
result.dispatch
);
assert!(
(result.objective - 2200.0).abs() < 1e-6,
"{}",
result.objective
);
assert!((result.lmp[0] - 10.0).abs() < 1e-6, "{:?}", result.lmp);
assert!((result.lmp[1] - 30.0).abs() < 1e-6, "{:?}", result.lmp);
assert_eq!(result.binding.len(), 1, "{:?}", result.binding);
let binding = &result.binding[0];
assert_eq!(binding.branch, 0);
assert!((binding.flow - 40.0).abs() < 1e-6, "{binding:?}");
assert!((binding.rate - 40.0).abs() < 1e-6, "{binding:?}");
assert!((binding.price.abs() - 20.0).abs() < 1e-6, "{binding:?}");
}
#[test]
fn unservable_demand_is_shed_rather_than_reported_infeasible() {
let mut network = two_bus(None);
network.generators[0].p_max = 0.2; network.generators[1].p_max = 0.3; let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert_eq!(solution.status, OptStatus::Optimal);
let result = opf.interpret(&solution);
let shed: f64 = result.shed.iter().sum();
assert!(
(shed - 50.0).abs() < 1e-6,
"expected 50 MW shed, got {shed}"
);
assert!(result.lmp[1] > 1000.0, "{:?}", result.lmp);
}
#[test]
fn without_shedding_unservable_demand_is_infeasible() {
let mut network = two_bus(None);
network.generators[0].p_max = 0.2;
network.generators[1].p_max = 0.3;
let options = DcOpfOptions {
allow_shedding: false,
..Default::default()
};
let opf = DcOpf::build(network, options).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert!(!solution.is_optimal(), "status was {:?}", solution.status);
}
#[test]
fn every_pglib_case_solves_to_a_certified_optimum() {
for &(name, _) in PUBLISHED_DC {
let network = load_case(name);
let total_load: f64 = network.loads.iter().map(|l| l.p).sum();
let base = network.base_mva;
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert_eq!(solution.status, OptStatus::Optimal, "{name}");
assert_kkt(&opf, &solution, 1e-6);
let result = opf.interpret(&solution);
let generated: f64 = result.dispatch.iter().sum();
let shed: f64 = result.shed.iter().sum();
assert!(
(generated + shed - total_load * base).abs() < 1e-4,
"{name}: generated {generated} + shed {shed} != load {}",
total_load * base
);
assert!(
shed < 1e-6,
"{name}: shed {shed} MW on a case that should be servable"
);
}
}
#[test]
fn objectives_match_the_published_dc_baseline() {
for &(name, published) in PUBLISHED_DC {
let network = load_case(name);
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
assert_eq!(solution.status, OptStatus::Optimal, "{name}");
let result = opf.interpret(&solution);
let relative = (result.objective - published).abs() / published.abs();
assert!(
relative < 5e-4,
"{name}: objective {:.4e} vs published {published:.4e} ({:.4}% apart)",
result.objective,
relative * 100.0
);
}
}
#[test]
fn the_textbook_susceptance_is_measurably_further_from_the_baseline() {
let gap = |name: &str, approximation| {
let (input, data) = load_documents(name);
let network =
gridoxide::pgm::opf::dc_opf_network_from_pgm(input, &data, 50.0, approximation)
.unwrap();
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let published = PUBLISHED_DC.iter().find(|(n, _)| *n == name).unwrap().1;
let objective = opf
.interpret(&solver().solve(opf.problem()).unwrap())
.objective;
(objective - published) / published
};
let case30 = gap("pglib_opf_case30_ieee", DcApproximation::IgnoreR);
assert!(
(case30 - 0.00423).abs() < 5e-4,
"case30 with b = 1/x should sit ~+0.42% above the baseline, got {:.4}%",
case30 * 100.0
);
for &(name, _) in PUBLISHED_DC {
let textbook = gap(name, DcApproximation::IgnoreR).abs();
let series = gap(name, DcApproximation::IgnoreG).abs();
assert!(
series <= textbook + 1e-9,
"{name}: b = x/(r²+x²) is {series:.2e} off, worse than b = 1/x at {textbook:.2e}"
);
}
}
#[test]
fn prices_spread_only_where_the_network_binds() {
for &(name, _) in PUBLISHED_DC {
let network = load_case(name);
let opf = DcOpf::build(network, DcOpfOptions::default()).unwrap();
let solution = solver().solve(opf.problem()).unwrap();
let result = opf.interpret(&solution);
let min = result.lmp.iter().cloned().fold(f64::INFINITY, f64::min);
let max = result.lmp.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
if result.binding.is_empty() {
assert!(
(max - min).abs() < 1e-6,
"{name}: nothing binds, yet prices span {min}..{max}"
);
}
assert!(min > -1e-6, "{name}: a negative price of {min}");
}
}
fn two_bus(rate: Option<f64>) -> DcOpfNetwork {
DcOpfNetwork {
n_buses: 2,
reference: 0,
branches: vec![DcBranch {
index: 0,
from: 0,
to: 1,
b: 10.0,
shift: 0.0,
}],
limits: HashMap::from([(0, rate)]),
generators: vec![
DcGenerator {
index: 0,
bus: 0,
p_min: 0.0,
p_max: 2.0,
cost: Some(CostCurve::Polynomial {
coefficients: vec![0.0, 1000.0],
}),
},
DcGenerator {
index: 1,
bus: 1,
p_min: 0.0,
p_max: 2.0,
cost: Some(CostCurve::Polynomial {
coefficients: vec![0.0, 3000.0],
}),
},
],
loads: vec![DcLoad { bus: 1, p: 1.0 }],
base_mva: 100.0,
}
}