#![cfg(all(feature = "opf", feature = "opf-highs"))]
use gridoxide::linear::DcApproximation;
use gridoxide::opf::dc::{DcOpf, DcOpfOptions};
use gridoxide::opf::highs::HighsSolver;
use gridoxide::opf::ipm::IpmSolver;
use gridoxide::opf::model::OpfData;
use gridoxide::opf::{LinearProgram, OptStatus, Solution, Solver};
use gridoxide::pgm::PgmInput;
mod pglib;
const OBJECTIVE_TOL: f64 = 1e-9;
const DUAL_TOL: f64 = 1e-6;
const CASES: &[&str] = &[
"pglib_opf_case3_lmbd",
"pglib_opf_case5_pjm",
"pglib_opf_case14_ieee",
"pglib_opf_case30_ieee",
"pglib_opf_case118_ieee",
];
fn dc_opf(name: &str, options: DcOpfOptions) -> DcOpf {
let dir = pglib::dir();
let input: PgmInput =
serde_json::from_str(&std::fs::read_to_string(dir.join(format!("{name}.json"))).unwrap())
.unwrap();
let data =
OpfData::from_json(&std::fs::read_to_string(dir.join(format!("{name}.opf.json"))).unwrap())
.unwrap();
let network =
gridoxide::pgm::opf::dc_opf_network_from_pgm(input, &data, 50.0, DcApproximation::IgnoreG)
.unwrap();
DcOpf::build(network, options).unwrap()
}
fn both(lp: &LinearProgram) -> (Solution, Solution) {
let highs = HighsSolver::new().unwrap().solve(lp).unwrap();
let ipm = IpmSolver::new().solve(lp).unwrap();
(highs, ipm)
}
fn assert_objectives_agree(highs: &Solution, ipm: &Solution, what: &str) {
assert_eq!(
highs.status, ipm.status,
"{what}: the two backends disagree on status"
);
if highs.status != OptStatus::Optimal {
return;
}
let relative = (highs.objective - ipm.objective).abs() / highs.objective.abs().max(1.0);
assert!(
relative < OBJECTIVE_TOL,
"{what}: HiGHS {} vs interior-point {} ({relative:.3e} relative)",
highs.objective,
ipm.objective
);
}
#[test]
fn the_two_backends_agree_on_every_pglib_objective() {
for name in CASES {
let opf = dc_opf(name, DcOpfOptions::default());
let (highs, ipm) = both(opf.problem());
assert_objectives_agree(&highs, &ipm, name);
}
}
#[test]
fn the_two_backends_agree_on_every_locational_marginal_price() {
for name in CASES {
let opf = dc_opf(name, DcOpfOptions::default());
let (highs, ipm) = both(opf.problem());
assert_eq!(highs.status, OptStatus::Optimal);
let a = opf.interpret(&highs);
let b = opf.interpret(&ipm);
for bus in 0..a.lmp.len() {
assert!(
(a.lmp[bus] - b.lmp[bus]).abs() < DUAL_TOL,
"{name} bus {bus}: HiGHS priced it at {} $/MWh, interior-point at {}",
a.lmp[bus],
b.lmp[bus]
);
}
let mut binding_a: Vec<usize> = a.binding.iter().map(|x| x.branch).collect();
let mut binding_b: Vec<usize> = b.binding.iter().map(|x| x.branch).collect();
binding_a.sort_unstable();
binding_b.sort_unstable();
assert_eq!(
binding_a, binding_b,
"{name}: the backends disagree on what binds"
);
}
}
#[test]
fn the_two_backends_agree_on_what_each_generator_produces() {
for name in CASES {
let opf = dc_opf(name, DcOpfOptions::default());
let (highs, ipm) = both(opf.problem());
let a = opf.interpret(&highs);
let b = opf.interpret(&ipm);
let total_a: f64 = a.dispatch.iter().sum();
let total_b: f64 = b.dispatch.iter().sum();
assert!(
(total_a - total_b).abs() < 1e-6,
"{name}: total generation {total_a} vs {total_b} MW"
);
for g in 0..a.dispatch.len() {
assert!(
(a.dispatch[g] - b.dispatch[g]).abs() < 1e-5,
"{name} generator {g}: {} vs {} MW",
a.dispatch[g],
b.dispatch[g]
);
}
}
}
#[test]
fn the_two_backends_agree_on_infeasible_and_unbounded_problems() {
let mut infeasible = LinearProgram::new(1);
infeasible.col_lower = vec![f64::NEG_INFINITY];
infeasible.col_upper = vec![f64::INFINITY];
infeasible.col_cost = vec![1.0];
infeasible.add_row(&[(0, 1.0)], 5.0, f64::INFINITY);
infeasible.add_row(&[(0, 1.0)], f64::NEG_INFINITY, 2.0);
let (highs, ipm) = both(&infeasible);
assert!(!highs.is_optimal() && !ipm.is_optimal());
assert_eq!(
ipm.status,
OptStatus::Infeasible,
"the interior-point verdict"
);
let mut unbounded = LinearProgram::new(1);
unbounded.col_lower = vec![f64::NEG_INFINITY];
unbounded.col_upper = vec![f64::INFINITY];
unbounded.col_cost = vec![1.0];
let (highs, ipm) = both(&unbounded);
assert!(!highs.is_optimal() && !ipm.is_optimal());
assert_eq!(
ipm.status,
OptStatus::Unbounded,
"the interior-point verdict"
);
}
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn range(&mut self, low: f64, high: f64) -> f64 {
low + (high - low) * (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
#[test]
fn the_two_backends_agree_on_randomized_convex_qps() {
let mut rng = Rng(0x5EED_1234_ABCD_0001);
let mut compared = 0;
for trial in 0..300 {
let n = 2 + (rng.next_u64() % 7) as usize;
let m = 1 + (rng.next_u64() % 6) as usize;
let mut lp = LinearProgram::new(n);
lp.col_cost = (0..n).map(|_| rng.range(-5.0, 5.0)).collect();
let l: Vec<Vec<f64>> = (0..n)
.map(|i| (0..=i).map(|_| rng.range(-1.5, 1.5)).collect::<Vec<f64>>())
.collect();
let mut hessian = Vec::new();
for i in 0..n {
for j in 0..=i {
let mut value: f64 = (0..=j.min(i)).map(|k| l[i][k] * l[j][k]).sum();
if i == j {
value += 0.25;
}
if value != 0.0 {
hessian.push((i, j, value));
}
}
}
lp.hessian = Some(hessian);
let x0: Vec<f64> = (0..n).map(|_| rng.range(-3.0, 3.0)).collect();
for k in 0..n {
match rng.next_u64() % 4 {
0 => {
lp.col_lower[k] = f64::NEG_INFINITY;
lp.col_upper[k] = f64::INFINITY;
}
1 => {
lp.col_lower[k] = x0[k];
lp.col_upper[k] = x0[k];
}
2 => {
lp.col_lower[k] = x0[k] - rng.range(0.5, 4.0);
lp.col_upper[k] = f64::INFINITY;
}
_ => {
lp.col_lower[k] = x0[k] - rng.range(0.5, 4.0);
lp.col_upper[k] = x0[k] + rng.range(0.5, 4.0);
}
}
}
for _ in 0..m {
let mut coefficients: Vec<(usize, f64)> = Vec::new();
for k in 0..n {
if rng.next_u64() % 3 != 0 {
coefficients.push((k, rng.range(-2.0, 2.0)));
}
}
if coefficients.is_empty() {
continue;
}
let activity: f64 = coefficients.iter().map(|&(k, v)| v * x0[k]).sum();
match rng.next_u64() % 3 {
0 => lp.add_row(&coefficients, activity, activity),
1 => lp.add_row(&coefficients, activity - rng.range(0.1, 3.0), f64::INFINITY),
_ => lp.add_row(
&coefficients,
activity - rng.range(0.1, 3.0),
activity + rng.range(0.1, 3.0),
),
};
}
lp.validate()
.unwrap_or_else(|e| panic!("trial {trial} built an invalid problem: {e}"));
let highs = HighsSolver::new().unwrap().solve(&lp).unwrap();
let ipm = IpmSolver::new().solve(&lp).unwrap();
if highs.status != OptStatus::Optimal {
continue;
}
assert_eq!(
ipm.status,
OptStatus::Optimal,
"trial {trial}: HiGHS solved it, interior-point returned {:?}",
ipm.status
);
assert_objectives_agree(&highs, &ipm, &format!("trial {trial}"));
for k in 0..n {
let tolerance = 1e-5 * (1.0 + highs.primal[k].abs());
assert!(
(highs.primal[k] - ipm.primal[k]).abs() < tolerance,
"trial {trial} x{k}: HiGHS {} vs interior-point {}",
highs.primal[k],
ipm.primal[k]
);
}
compared += 1;
}
assert!(
compared > 250,
"only {compared} of 300 trials produced a comparison"
);
}