use serde::Deserialize;
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(tag = "model", rename_all = "snake_case")]
pub enum CostCurve {
Polynomial { coefficients: Vec<f64> },
PiecewiseLinear { points: Vec<(f64, f64)> },
}
impl CostCurve {
pub fn evaluate(&self, p: f64) -> f64 {
match self {
Self::Polynomial { coefficients } => {
coefficients.iter().rev().fold(0.0, |acc, c| acc * p + c)
}
Self::PiecewiseLinear { points } => match points.len() {
0 => 0.0,
1 => points[0].1,
_ => {
let segment = points
.windows(2)
.find(|w| p <= w[1].0)
.unwrap_or_else(|| &points[points.len() - 2..]);
let ((x0, y0), (x1, y1)) = (segment[0], segment[1]);
if x1 == x0 {
y0
} else {
y0 + (y1 - y0) * (p - x0) / (x1 - x0)
}
}
},
}
}
pub fn marginal(&self, p: f64) -> f64 {
match self {
Self::Polynomial { coefficients } => coefficients
.iter()
.enumerate()
.skip(1)
.map(|(k, c)| c * (k as f64) * p.powi(k as i32 - 1))
.sum(),
Self::PiecewiseLinear { points } => {
if points.len() < 2 {
return 0.0;
}
let segment = points
.windows(2)
.find(|w| p <= w[1].0)
.unwrap_or_else(|| &points[points.len() - 2..]);
let ((x0, y0), (x1, y1)) = (segment[0], segment[1]);
if x1 == x0 { 0.0 } else { (y1 - y0) / (x1 - x0) }
}
}
}
pub fn to_per_unit(&self, base_mva: f64) -> Self {
match self {
Self::Polynomial { coefficients } => Self::Polynomial {
coefficients: coefficients
.iter()
.enumerate()
.map(|(k, c)| c * base_mva.powi(k as i32))
.collect(),
},
Self::PiecewiseLinear { points } => Self::PiecewiseLinear {
points: points.iter().map(|&(x, y)| (x / base_mva, y)).collect(),
},
}
}
pub fn is_convex(&self) -> bool {
match self {
Self::Polynomial { coefficients } => match coefficients.len() {
0..=2 => true,
3 => coefficients[2] >= 0.0,
_ => false,
},
Self::PiecewiseLinear { points } => points
.windows(3)
.all(|w| slope(w[0], w[1]) <= slope(w[1], w[2]) + 1e-12),
}
}
}
fn slope(a: (f64, f64), b: (f64, f64)) -> f64 {
if b.0 == a.0 {
0.0
} else {
(b.1 - a.1) / (b.0 - a.0)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Generator {
pub index: usize,
pub node: u64,
pub p_min: f64,
pub p_max: f64,
#[serde(default)]
pub q_min: Option<f64>,
#[serde(default)]
pub q_max: Option<f64>,
#[serde(default)]
pub cost: Option<CostCurve>,
}
impl Generator {
pub fn p_limits_pu(&self, base_mva: f64) -> (f64, f64) {
(self.p_min / base_mva, self.p_max / base_mva)
}
pub fn q_limits_pu(&self, base_mva: f64) -> (f64, f64) {
(
self.q_min.map_or(f64::NEG_INFINITY, |q| q / base_mva),
self.q_max.map_or(f64::INFINITY, |q| q / base_mva),
)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct BusVoltage {
pub node: u64,
pub v_min: f64,
pub v_max: f64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct BranchLimit {
pub id: u64,
pub rate_a: f64,
#[serde(default)]
pub unlimited: bool,
}
impl BranchLimit {
pub fn rate_pu(&self, base_mva: f64) -> Option<f64> {
(!self.unlimited).then(|| self.rate_a / base_mva)
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Load {
pub id: u64,
pub node: u64,
}
#[derive(Clone, Debug, Deserialize)]
pub struct OpfData {
pub base_mva: f64,
#[serde(default)]
pub generator: Vec<Generator>,
#[serde(default)]
pub bus_voltage: Vec<BusVoltage>,
#[serde(default)]
pub branch_limit: Vec<BranchLimit>,
#[serde(default)]
pub load: Vec<Load>,
}
impl OpfData {
pub fn from_json(text: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(text)
}
pub fn costs_are_convex(&self) -> bool {
self.generator
.iter()
.filter_map(|g| g.cost.as_ref())
.all(CostCurve::is_convex)
}
pub fn binding_capable_branches(&self) -> usize {
self.branch_limit.iter().filter(|b| !b.unlimited).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quadratic() -> CostCurve {
CostCurve::Polynomial {
coefficients: vec![0.0, 20.0, 0.043],
}
}
#[test]
fn a_polynomial_is_evaluated_in_ascending_order() {
let c = quadratic();
assert!((c.evaluate(10.0) - 204.3).abs() < 1e-12);
assert!((c.marginal(10.0) - 20.86).abs() < 1e-12);
}
#[test]
fn per_unit_conversion_scales_each_coefficient_by_its_own_power() {
let pu = quadratic().to_per_unit(100.0);
let CostCurve::Polynomial { coefficients } = &pu else {
panic!("still polynomial")
};
assert!((coefficients[0] - 0.0).abs() < 1e-12);
assert!((coefficients[1] - 2000.0).abs() < 1e-12, "{coefficients:?}");
assert!((coefficients[2] - 430.0).abs() < 1e-12, "{coefficients:?}");
let mw = quadratic().evaluate(10.0);
let per_unit = pu.evaluate(10.0 / 100.0);
assert!((mw - per_unit).abs() < 1e-9, "{mw} vs {per_unit}");
}
#[test]
fn a_piecewise_curve_interpolates_and_extends_its_end_segments() {
let c = CostCurve::PiecewiseLinear {
points: vec![(0.0, 0.0), (10.0, 100.0), (20.0, 300.0)],
};
assert!((c.evaluate(5.0) - 50.0).abs() < 1e-12);
assert!((c.evaluate(15.0) - 200.0).abs() < 1e-12);
assert!((c.marginal(5.0) - 10.0).abs() < 1e-12);
assert!((c.marginal(15.0) - 20.0).abs() < 1e-12);
assert!((c.evaluate(25.0) - 400.0).abs() < 1e-12);
}
#[test]
fn convexity_is_judged_on_the_shape_that_matters() {
assert!(quadratic().is_convex());
assert!(
CostCurve::Polynomial {
coefficients: vec![1.0, 2.0]
}
.is_convex()
);
assert!(
!CostCurve::Polynomial {
coefficients: vec![0.0, 20.0, -0.5]
}
.is_convex()
);
assert!(
!CostCurve::Polynomial {
coefficients: vec![0.0, 1.0, 1.0, 1.0]
}
.is_convex()
);
assert!(
CostCurve::PiecewiseLinear {
points: vec![(0.0, 0.0), (10.0, 100.0), (20.0, 300.0)]
}
.is_convex()
);
assert!(
!CostCurve::PiecewiseLinear {
points: vec![(0.0, 0.0), (10.0, 300.0), (20.0, 400.0)]
}
.is_convex()
);
}
#[test]
fn a_zero_rate_reads_as_unlimited_not_as_a_binding_zero() {
let unlimited = BranchLimit {
id: 1,
rate_a: 0.0,
unlimited: true,
};
assert_eq!(unlimited.rate_pu(100.0), None);
let rated = BranchLimit {
id: 2,
rate_a: 250.0,
unlimited: false,
};
assert_eq!(rated.rate_pu(100.0), Some(2.5));
}
#[test]
fn the_document_round_trips_from_json() {
let text = r#"{
"version": "1.0", "type": "opf_input", "base_mva": 100.0,
"generator": [
{"index": 0, "node": 1, "p_min": 0.0, "p_max": 332.4,
"q_min": -16.9, "q_max": 10.0,
"cost": {"model": "polynomial", "coefficients": [0.0, 20.0, 0.043]}},
{"index": 1, "node": 2, "p_min": 0.0, "p_max": 140.0}
],
"branch_limit": [
{"id": 700, "rate_a": 300.0, "unlimited": false},
{"id": 701, "rate_a": 0.0, "unlimited": true}
],
"load": [{"id": 800, "node": 3}]
}"#;
let data = OpfData::from_json(text).unwrap();
assert_eq!(data.base_mva, 100.0);
assert_eq!(data.generator.len(), 2);
assert_eq!(data.generator[0].p_limits_pu(100.0), (0.0, 3.324));
assert_eq!(data.generator[0].q_min, Some(-16.9));
assert!(data.generator[1].cost.is_none());
assert!(data.generator[1].q_max.is_none());
assert_eq!(data.binding_capable_branches(), 1);
assert!(data.costs_are_convex());
assert_eq!(data.load[0].node, 3);
}
}